QAway-to commited on
Commit
f5bade2
Β·
1 Parent(s): 7e2057c

Restore original tab layout while keeping cached data

Browse files
Files changed (32) hide show
  1. README.md +11 -0
  2. app.py +38 -19
  3. application/__init__.py +13 -0
  4. core/chat.py β†’ application/chat_assistant.py +2 -1
  5. core/metrics.py β†’ application/metrics_table.py +15 -5
  6. core/analyzer.py β†’ application/portfolio_analyzer.py +9 -3
  7. core/comparer.py β†’ application/portfolio_comparer.py +10 -4
  8. config.py +4 -0
  9. core/__init__.py +13 -2
  10. domain/__init__.py +3 -0
  11. infrastructure/__init__.py +20 -0
  12. infrastructure/cache.py +167 -0
  13. {services β†’ infrastructure}/llm_client.py +0 -0
  14. infrastructure/market_data/__init__.py +5 -0
  15. core/data_binance.py β†’ infrastructure/market_data/binance.py +0 -0
  16. core/data_coinlore.py β†’ infrastructure/market_data/coinlore.py +0 -0
  17. core/data_yfinance.py β†’ infrastructure/market_data/yfinance.py +0 -0
  18. {services β†’ infrastructure}/output_api.py +26 -1
  19. presentation/__init__.py +5 -0
  20. presentation/components/__init__.py +12 -0
  21. {core β†’ presentation/components}/comparison_table.py +90 -21
  22. {core β†’ presentation/components}/crypto_dashboard.py +3 -1
  23. {core β†’ presentation/components}/multi_charts.py +0 -0
  24. {core β†’ presentation/components}/visual_comparison.py +71 -11
  25. {core β†’ presentation/components}/visualization.py +0 -0
  26. presentation/styles/__init__.py +5 -0
  27. presentation/styles/themes/__init__.py +3 -0
  28. {core/styles β†’ presentation/styles/themes}/base.css +0 -0
  29. {core/styles β†’ presentation/styles/themes}/crypto_dashboard.css +0 -0
  30. {core/styles β†’ presentation/styles/themes}/multi_charts.css +0 -0
  31. {core β†’ presentation/styles}/ui_style.css +0 -0
  32. services/__init__.py +0 -2
README.md CHANGED
@@ -11,3 +11,14 @@ license: apache-2.0
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
14
+
15
+ ## Repository layout
16
+
17
+ The prototype is now organized into lightweight layers to keep responsibilities clear even in a demo setting:
18
+
19
+ - `application/` – orchestration services that combine prompts with infrastructure adapters.
20
+ - `infrastructure/` – clients for external APIs and market data providers (Featherless, Coinlore, etc.).
21
+ - `presentation/` – Gradio components, dashboards, and CSS themes displayed in the Space UI.
22
+ - `domain/` – placeholder for future data models specific to the investment analytics domain.
23
+
24
+ `app.py` wires these pieces together to expose the multi-tab Gradio experience on Hugging Face Spaces.
app.py CHANGED
@@ -1,11 +1,19 @@
1
  import gradio as gr
2
- from services.llm_client import llm_service
3
- from core.analyzer import PortfolioAnalyzer
4
- from core.comparer import PortfolioComparer
5
- from core.chat import ChatAssistant
6
- from core.metrics import show_metrics_table
7
- from core.crypto_dashboard import build_crypto_dashboard # Plotly dashboard + KPI-line
8
- from core.visual_comparison import build_price_chart, build_volatility_chart # Interactive pair comparison
 
 
 
 
 
 
 
 
9
 
10
  # === CSS loader ===
11
  def load_css(path: str) -> str:
@@ -13,8 +21,8 @@ def load_css(path: str) -> str:
13
  return f.read()
14
 
15
  # === Styles ===
16
- base_css = load_css("core/styles/base.css")
17
- crypto_css = load_css("core/styles/crypto_dashboard.css")
18
 
19
  # === Model setup ===
20
  MODEL_NAME = "meta-llama/Meta-Llama-3.1-8B-Instruct"
@@ -39,18 +47,35 @@ with gr.Blocks(css=base_css) as demo:
39
  value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb",
40
  )
41
  analyze_btn = gr.Button("Run Analysis", variant="primary")
42
- analyze_out = gr.Textbox(label="Analysis Result", lines=15, elem_id="analysis_output")
 
 
 
 
43
  analyze_btn.click(fn=analyzer.run, inputs=portfolio_input, outputs=analyze_out)
44
 
45
  # --- Comparison Table ---
46
  with gr.TabItem("Comparison Table"):
47
- from core.comparison_table import show_comparison_table
48
  pid_a = gr.Textbox(label="Portfolio A", value="3852a354-e66e-4bc5-97e9-55124e31e687")
49
  pid_b = gr.Textbox(label="Portfolio B", value="b1ef37aa-5b9a-41b4-8823f2de36bb")
50
  compare_btn = gr.Button("Load Comparison", variant="primary")
51
  comp_table = gr.Dataframe(label="Comparative Metrics", wrap=True)
52
  comp_comment = gr.Textbox(label="AI Commentary", lines=14, elem_id="llm_comment_box")
53
- compare_btn.click(fn=show_comparison_table, inputs=[pid_a, pid_b], outputs=[comp_table, comp_comment])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  # --- Assistant ---
56
  with gr.TabItem("Assistant"):
@@ -59,13 +84,6 @@ with gr.Blocks(css=base_css) as demo:
59
  chat_out = gr.Textbox(label="AI Response", lines=8)
60
  chat_btn.click(fn=chatbot.run, inputs=chat_in, outputs=chat_out)
61
 
62
- # --- Metrics Table ---
63
- with gr.TabItem("Metrics Table"):
64
- metrics_in = gr.Textbox(label="Portfolio ID", value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb")
65
- metrics_btn = gr.Button("Load Metrics", variant="primary")
66
- metrics_out = gr.Dataframe(label="Portfolio Metrics", wrap=True)
67
- metrics_btn.click(fn=show_metrics_table, inputs=metrics_in, outputs=metrics_out)
68
-
69
  # --- Visual Comparison (Interactive Plotly Edition) ---
70
  with gr.TabItem("Visual Comparison"):
71
  gr.Markdown("### πŸ“Š Market Pair Comparison β€” Interactive Plotly Edition")
@@ -97,6 +115,7 @@ with gr.Blocks(css=base_css) as demo:
97
  pair_selector.change(fn=update_visuals, inputs=pair_selector, outputs=[price_plot, vol_plot])
98
 
99
  def init_visuals():
 
100
  return update_visuals("Bitcoin vs Ethereum")
101
 
102
  demo.load(fn=init_visuals, inputs=None, outputs=[price_plot, vol_plot])
 
1
  import gradio as gr
2
+
3
+ from application.chat_assistant import ChatAssistant
4
+ from application.metrics_table import show_metrics_table
5
+ from application.portfolio_analyzer import PortfolioAnalyzer
6
+ from application.portfolio_comparer import PortfolioComparer
7
+ from infrastructure.llm_client import llm_service
8
+ from presentation.components.crypto_dashboard import (
9
+ build_crypto_dashboard,
10
+ ) # Plotly dashboard + KPI-line
11
+ from presentation.components.comparison_table import show_comparison_table
12
+ from presentation.components.visual_comparison import (
13
+ build_price_chart,
14
+ build_volatility_chart,
15
+ preload_pairs,
16
+ ) # Interactive pair comparison
17
 
18
  # === CSS loader ===
19
  def load_css(path: str) -> str:
 
21
  return f.read()
22
 
23
  # === Styles ===
24
+ base_css = load_css("presentation/styles/themes/base.css")
25
+ crypto_css = load_css("presentation/styles/themes/crypto_dashboard.css")
26
 
27
  # === Model setup ===
28
  MODEL_NAME = "meta-llama/Meta-Llama-3.1-8B-Instruct"
 
47
  value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb",
48
  )
49
  analyze_btn = gr.Button("Run Analysis", variant="primary")
50
+ analyze_out = gr.Textbox(
51
+ label="Analysis Result",
52
+ lines=15,
53
+ elem_id="analysis_output",
54
+ )
55
  analyze_btn.click(fn=analyzer.run, inputs=portfolio_input, outputs=analyze_out)
56
 
57
  # --- Comparison Table ---
58
  with gr.TabItem("Comparison Table"):
 
59
  pid_a = gr.Textbox(label="Portfolio A", value="3852a354-e66e-4bc5-97e9-55124e31e687")
60
  pid_b = gr.Textbox(label="Portfolio B", value="b1ef37aa-5b9a-41b4-8823f2de36bb")
61
  compare_btn = gr.Button("Load Comparison", variant="primary")
62
  comp_table = gr.Dataframe(label="Comparative Metrics", wrap=True)
63
  comp_comment = gr.Textbox(label="AI Commentary", lines=14, elem_id="llm_comment_box")
64
+ compare_btn.click(
65
+ fn=show_comparison_table,
66
+ inputs=[pid_a, pid_b],
67
+ outputs=[comp_table, comp_comment],
68
+ )
69
+
70
+ # --- Metrics Table ---
71
+ with gr.TabItem("Metrics Table"):
72
+ metrics_in = gr.Textbox(
73
+ label="Portfolio ID",
74
+ value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb",
75
+ )
76
+ metrics_btn = gr.Button("Load Metrics", variant="primary")
77
+ metrics_out = gr.Dataframe(label="Portfolio Metrics", wrap=True)
78
+ metrics_btn.click(fn=show_metrics_table, inputs=metrics_in, outputs=metrics_out)
79
 
80
  # --- Assistant ---
81
  with gr.TabItem("Assistant"):
 
84
  chat_out = gr.Textbox(label="AI Response", lines=8)
85
  chat_btn.click(fn=chatbot.run, inputs=chat_in, outputs=chat_out)
86
 
 
 
 
 
 
 
 
87
  # --- Visual Comparison (Interactive Plotly Edition) ---
88
  with gr.TabItem("Visual Comparison"):
89
  gr.Markdown("### πŸ“Š Market Pair Comparison β€” Interactive Plotly Edition")
 
115
  pair_selector.change(fn=update_visuals, inputs=pair_selector, outputs=[price_plot, vol_plot])
116
 
117
  def init_visuals():
118
+ preload_pairs(available_pairs)
119
  return update_visuals("Bitcoin vs Ethereum")
120
 
121
  demo.load(fn=init_visuals, inputs=None, outputs=[price_plot, vol_plot])
application/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application layer services orchestrating domain and infrastructure."""
2
+
3
+ from .chat_assistant import ChatAssistant
4
+ from .metrics_table import show_metrics_table
5
+ from .portfolio_analyzer import PortfolioAnalyzer
6
+ from .portfolio_comparer import PortfolioComparer
7
+
8
+ __all__ = [
9
+ "ChatAssistant",
10
+ "PortfolioAnalyzer",
11
+ "PortfolioComparer",
12
+ "show_metrics_table",
13
+ ]
core/chat.py β†’ application/chat_assistant.py RENAMED
@@ -7,7 +7,8 @@ Purpose: General chat interface for user questions about investments or portfoli
7
  """
8
 
9
  from typing import Generator
10
- from services.llm_client import llm_service
 
11
  from prompts.system_prompts import GENERAL_CONTEXT
12
 
13
 
 
7
  """
8
 
9
  from typing import Generator
10
+
11
+ from infrastructure.llm_client import llm_service
12
  from prompts.system_prompts import GENERAL_CONTEXT
13
 
14
 
core/metrics.py β†’ application/metrics_table.py RENAMED
@@ -6,29 +6,39 @@ Purpose: Provides async utilities to fetch and display portfolio metrics as a Da
6
  НазначСниС: прСдоставляСт асинхронныС Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ для получСния ΠΈ отобраТСния ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊ портфСля Π² Π²ΠΈΠ΄Π΅ DataFrame.
7
  """
8
 
9
- import pandas as pd
10
  import asyncio
11
- from services.output_api import extract_portfolio_id, fetch_metrics_async
 
 
 
 
12
 
13
 
14
  def show_metrics_table(portfolio_input: str):
15
  """Fetch portfolio metrics and return them as a DataFrame for Gradio."""
16
  pid = extract_portfolio_id(portfolio_input)
17
  if not pid:
18
- return "❌ Invalid portfolioId format."
19
 
20
  try:
21
  df = asyncio.run(_get_metrics_df(pid))
22
  return df
 
 
 
23
  except Exception as e:
24
- return f"❌ Error fetching metrics: {e}"
25
 
26
 
27
  async def _get_metrics_df(portfolio_id: str) -> pd.DataFrame:
28
  """Internal helper to asynchronously get metrics."""
29
- metrics = await fetch_metrics_async(portfolio_id)
30
  if not metrics:
31
  raise ValueError("No metrics found for given portfolio.")
32
 
33
  df = pd.DataFrame(list(metrics.items()), columns=["Metric", "Value"])
34
  return df
 
 
 
 
 
6
  НазначСниС: прСдоставляСт асинхронныС Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ для получСния ΠΈ отобраТСния ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊ портфСля Π² Π²ΠΈΠ΄Π΅ DataFrame.
7
  """
8
 
 
9
  import asyncio
10
+
11
+ import pandas as pd
12
+
13
+ from infrastructure.cache import CacheUnavailableError
14
+ from infrastructure.output_api import extract_portfolio_id, fetch_metrics_cached
15
 
16
 
17
  def show_metrics_table(portfolio_input: str):
18
  """Fetch portfolio metrics and return them as a DataFrame for Gradio."""
19
  pid = extract_portfolio_id(portfolio_input)
20
  if not pid:
21
+ return _message_df("❌ Invalid portfolioId format.")
22
 
23
  try:
24
  df = asyncio.run(_get_metrics_df(pid))
25
  return df
26
+ except CacheUnavailableError as e:
27
+ wait = int(e.retry_in) + 1
28
+ return _message_df(f"⚠️ Metrics API cooling down. Retry in ~{wait} seconds.")
29
  except Exception as e:
30
+ return _message_df(f"❌ Error fetching metrics: {e}")
31
 
32
 
33
  async def _get_metrics_df(portfolio_id: str) -> pd.DataFrame:
34
  """Internal helper to asynchronously get metrics."""
35
+ metrics = await fetch_metrics_cached(portfolio_id)
36
  if not metrics:
37
  raise ValueError("No metrics found for given portfolio.")
38
 
39
  df = pd.DataFrame(list(metrics.items()), columns=["Metric", "Value"])
40
  return df
41
+
42
+
43
+ def _message_df(message: str) -> pd.DataFrame:
44
+ return pd.DataFrame({"Message": [message]})
core/analyzer.py β†’ application/portfolio_analyzer.py RENAMED
@@ -8,8 +8,10 @@ Purpose: Handles single-portfolio analysis using LLM. Fetches metrics, builds pr
8
 
9
  import asyncio
10
  from typing import Generator
11
- from services.output_api import extract_portfolio_id, fetch_metrics_async
12
- from services.llm_client import llm_service
 
 
13
  from prompts.system_prompts import ANALYSIS_SYSTEM_PROMPT
14
  from prompts.reference_templates import REFERENCE_PROMPT
15
 
@@ -30,7 +32,11 @@ class PortfolioAnalyzer:
30
 
31
  yield "⏳ Working..."
32
  try:
33
- metrics = asyncio.run(fetch_metrics_async(portfolio_id))
 
 
 
 
34
  except Exception as e:
35
  yield f"❌ Fail to collect metrics: {e}"
36
  return
 
8
 
9
  import asyncio
10
  from typing import Generator
11
+
12
+ from infrastructure.cache import CacheUnavailableError
13
+ from infrastructure.output_api import extract_portfolio_id, fetch_metrics_cached
14
+ from infrastructure.llm_client import llm_service
15
  from prompts.system_prompts import ANALYSIS_SYSTEM_PROMPT
16
  from prompts.reference_templates import REFERENCE_PROMPT
17
 
 
32
 
33
  yield "⏳ Working..."
34
  try:
35
+ metrics = asyncio.run(fetch_metrics_cached(portfolio_id))
36
+ except CacheUnavailableError as e:
37
+ wait = int(e.retry_in) + 1
38
+ yield f"⚠️ API temporarily unavailable. Please retry in ~{wait} seconds."
39
+ return
40
  except Exception as e:
41
  yield f"❌ Fail to collect metrics: {e}"
42
  return
core/comparer.py β†’ application/portfolio_comparer.py RENAMED
@@ -8,8 +8,10 @@ Purpose: Compares two portfolios using LLM. Fetches metrics for both and builds
8
 
9
  import asyncio
10
  from typing import Generator
11
- from services.output_api import extract_portfolio_id, fetch_metrics_async
12
- from services.llm_client import llm_service
 
 
13
  from prompts.system_prompts import COMPARISON_SYSTEM_PROMPT
14
  from prompts.reference_templates import REFERENCE_COMPARISON_PROMPT
15
 
@@ -35,8 +37,12 @@ class PortfolioComparer:
35
 
36
  yield "⏳ Working..."
37
  try:
38
- m1 = asyncio.run(fetch_metrics_async(id1))
39
- m2 = asyncio.run(fetch_metrics_async(id2))
 
 
 
 
40
  except Exception as e:
41
  yield f"❌ There are issue via collecting data: {e}"
42
  return
 
8
 
9
  import asyncio
10
  from typing import Generator
11
+
12
+ from infrastructure.cache import CacheUnavailableError
13
+ from infrastructure.output_api import extract_portfolio_id, fetch_metrics_cached
14
+ from infrastructure.llm_client import llm_service
15
  from prompts.system_prompts import COMPARISON_SYSTEM_PROMPT
16
  from prompts.reference_templates import REFERENCE_COMPARISON_PROMPT
17
 
 
37
 
38
  yield "⏳ Working..."
39
  try:
40
+ m1 = asyncio.run(fetch_metrics_cached(id1))
41
+ m2 = asyncio.run(fetch_metrics_cached(id2))
42
+ except CacheUnavailableError as e:
43
+ wait = int(e.retry_in) + 1
44
+ yield f"⚠️ API temporarily unavailable. Retry in ~{wait} seconds."
45
+ return
46
  except Exception as e:
47
  yield f"❌ There are issue via collecting data: {e}"
48
  return
config.py CHANGED
@@ -18,3 +18,7 @@ EXTERNAL_API_URL = os.getenv("EXTERNAL_API_URL")
18
  # === Request / Connection Settings ===
19
  REQUEST_TIMEOUT = 15
20
  DEBUG = os.getenv("DEBUG", "false").lower() == "true"
 
 
 
 
 
18
  # === Request / Connection Settings ===
19
  REQUEST_TIMEOUT = 15
20
  DEBUG = os.getenv("DEBUG", "false").lower() == "true"
21
+
22
+ # === Caching Settings ===
23
+ CACHE_TTL_SECONDS = int(os.getenv("CACHE_TTL_SECONDS", "600")) # 10 minutes
24
+ CACHE_RETRY_SECONDS = int(os.getenv("CACHE_RETRY_SECONDS", "30")) # cooldown after failures
core/__init__.py CHANGED
@@ -1,2 +1,13 @@
1
- # __init__.py
2
- # Marks this directory as a Python package.
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Legacy compatibility layer bridging old imports to the new structure."""
2
+
3
+ from application.chat_assistant import ChatAssistant
4
+ from application.metrics_table import show_metrics_table
5
+ from application.portfolio_analyzer import PortfolioAnalyzer
6
+ from application.portfolio_comparer import PortfolioComparer
7
+
8
+ __all__ = [
9
+ "ChatAssistant",
10
+ "PortfolioAnalyzer",
11
+ "PortfolioComparer",
12
+ "show_metrics_table",
13
+ ]
domain/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Domain layer placeholder for future data models in the prototype."""
2
+
3
+ __all__: list[str] = []
infrastructure/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Infrastructure adapters for external services and data providers."""
2
+
3
+ from . import market_data
4
+ from .llm_client import FeatherlessLLM, llm_service
5
+ from .output_api import (
6
+ extract_portfolio_id,
7
+ fetch_absolute_pnl_async,
8
+ fetch_metrics_async,
9
+ fetch_metrics_cached,
10
+ )
11
+
12
+ __all__ = [
13
+ "FeatherlessLLM",
14
+ "llm_service",
15
+ "extract_portfolio_id",
16
+ "fetch_absolute_pnl_async",
17
+ "fetch_metrics_async",
18
+ "fetch_metrics_cached",
19
+ "market_data",
20
+ ]
infrastructure/cache.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utility caching primitives used across the demo application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from dataclasses import dataclass
8
+ from threading import Lock
9
+ from typing import Awaitable, Callable, Dict, Generic, Hashable, Optional, TypeVar
10
+
11
+ T = TypeVar("T")
12
+
13
+
14
+ class CacheUnavailableError(RuntimeError):
15
+ """Raised when cached resource is temporarily unavailable."""
16
+
17
+ def __init__(self, message: str, retry_in: float):
18
+ super().__init__(message)
19
+ self.retry_in = max(retry_in, 0.0)
20
+
21
+
22
+ @dataclass
23
+ class _CacheRecord(Generic[T]):
24
+ value: Optional[T]
25
+ expires_at: float
26
+ error_until: float
27
+ error_message: Optional[str]
28
+
29
+
30
+ class AsyncTTLCache(Generic[T]):
31
+ """Simple async-aware TTL cache with cooldown on failures."""
32
+
33
+ def __init__(self, ttl: float, retry_after: float):
34
+ self.ttl = ttl
35
+ self.retry_after = retry_after
36
+ self._store: Dict[Hashable, _CacheRecord[T]] = {}
37
+ self._locks: Dict[Hashable, asyncio.Lock] = {}
38
+ self._global_lock = asyncio.Lock()
39
+
40
+ async def get(self, key: Hashable, loader: Callable[[], Awaitable[T]]) -> T:
41
+ now = time.monotonic()
42
+ record = self._store.get(key)
43
+ if record:
44
+ if record.value is not None and now < record.expires_at:
45
+ return record.value
46
+ if record.error_message and now < record.error_until:
47
+ raise CacheUnavailableError(
48
+ record.error_message,
49
+ record.error_until - now,
50
+ )
51
+
52
+ lock = await self._get_lock(key)
53
+ async with lock:
54
+ now = time.monotonic()
55
+ record = self._store.get(key)
56
+ if record:
57
+ if record.value is not None and now < record.expires_at:
58
+ return record.value
59
+ if record.error_message and now < record.error_until:
60
+ raise CacheUnavailableError(
61
+ record.error_message,
62
+ record.error_until - now,
63
+ )
64
+
65
+ try:
66
+ value = await loader()
67
+ except CacheUnavailableError as exc:
68
+ cooldown = max(exc.retry_in, self.retry_after)
69
+ message = str(exc) or "Resource unavailable"
70
+ self._store[key] = _CacheRecord(
71
+ value=None,
72
+ expires_at=0.0,
73
+ error_until=now + cooldown,
74
+ error_message=message,
75
+ )
76
+ raise CacheUnavailableError(message, cooldown) from exc
77
+ except Exception as exc: # noqa: BLE001 - surface upstream
78
+ message = str(exc) or "Source request failed"
79
+ self._store[key] = _CacheRecord(
80
+ value=None,
81
+ expires_at=0.0,
82
+ error_until=now + self.retry_after,
83
+ error_message=message,
84
+ )
85
+ raise CacheUnavailableError(message, self.retry_after) from exc
86
+ else:
87
+ self._store[key] = _CacheRecord(
88
+ value=value,
89
+ expires_at=now + self.ttl,
90
+ error_until=0.0,
91
+ error_message=None,
92
+ )
93
+ return value
94
+
95
+ async def _get_lock(self, key: Hashable) -> asyncio.Lock:
96
+ lock = self._locks.get(key)
97
+ if lock is not None:
98
+ return lock
99
+ async with self._global_lock:
100
+ lock = self._locks.get(key)
101
+ if lock is None:
102
+ lock = asyncio.Lock()
103
+ self._locks[key] = lock
104
+ return lock
105
+
106
+
107
+ class TTLCache(Generic[T]):
108
+ """Synchronous TTL cache with cooldown control."""
109
+
110
+ def __init__(self, ttl: float, retry_after: float):
111
+ self.ttl = ttl
112
+ self.retry_after = retry_after
113
+ self._store: Dict[Hashable, _CacheRecord[T]] = {}
114
+ self._lock = Lock()
115
+
116
+ def get(self, key: Hashable, loader: Callable[[], T]) -> T:
117
+ now = time.monotonic()
118
+ record = self._store.get(key)
119
+ if record:
120
+ if record.value is not None and now < record.expires_at:
121
+ return record.value
122
+ if record.error_message and now < record.error_until:
123
+ raise CacheUnavailableError(
124
+ record.error_message,
125
+ record.error_until - now,
126
+ )
127
+
128
+ with self._lock:
129
+ now = time.monotonic()
130
+ record = self._store.get(key)
131
+ if record:
132
+ if record.value is not None and now < record.expires_at:
133
+ return record.value
134
+ if record.error_message and now < record.error_until:
135
+ raise CacheUnavailableError(
136
+ record.error_message,
137
+ record.error_until - now,
138
+ )
139
+ try:
140
+ value = loader()
141
+ except CacheUnavailableError as exc:
142
+ cooldown = max(exc.retry_in, self.retry_after)
143
+ message = str(exc) or "Resource unavailable"
144
+ self._store[key] = _CacheRecord(
145
+ value=None,
146
+ expires_at=0.0,
147
+ error_until=now + cooldown,
148
+ error_message=message,
149
+ )
150
+ raise CacheUnavailableError(message, cooldown) from exc
151
+ except Exception as exc: # noqa: BLE001 - propagate for visibility
152
+ message = str(exc) or "Source request failed"
153
+ self._store[key] = _CacheRecord(
154
+ value=None,
155
+ expires_at=0.0,
156
+ error_until=now + self.retry_after,
157
+ error_message=message,
158
+ )
159
+ raise CacheUnavailableError(message, self.retry_after) from exc
160
+ else:
161
+ self._store[key] = _CacheRecord(
162
+ value=value,
163
+ expires_at=now + self.ttl,
164
+ error_until=0.0,
165
+ error_message=None,
166
+ )
167
+ return value
{services β†’ infrastructure}/llm_client.py RENAMED
File without changes
infrastructure/market_data/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Market data providers used across the prototype."""
2
+
3
+ from . import binance, coinlore, yfinance
4
+
5
+ __all__ = ["binance", "coinlore", "yfinance"]
core/data_binance.py β†’ infrastructure/market_data/binance.py RENAMED
File without changes
core/data_coinlore.py β†’ infrastructure/market_data/coinlore.py RENAMED
File without changes
core/data_yfinance.py β†’ infrastructure/market_data/yfinance.py RENAMED
File without changes
{services β†’ infrastructure}/output_api.py RENAMED
@@ -11,7 +11,14 @@ Handles fetching metrics, alphaBTC data, and other portfolio information.
11
  import re
12
  import httpx
13
  from typing import Any, Dict, List, Optional
14
- from config import EXTERNAL_API_URL, REQUEST_TIMEOUT, DEBUG
 
 
 
 
 
 
 
15
 
16
  # === UUID detection ===
17
  UUID_PATTERN = re.compile(
@@ -59,6 +66,24 @@ async def fetch_metrics_async(portfolio_id: str) -> Optional[Dict[str, Any]]:
59
  return None
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  async def fetch_absolute_pnl_async(portfolio_id: str) -> Optional[List[Dict[str, Any]]]:
63
  """Fetch absolutePnL daily data."""
64
  url = f"{EXTERNAL_API_URL}/portfolio/get?portfolioId={portfolio_id}&extended=1&step=day"
 
11
  import re
12
  import httpx
13
  from typing import Any, Dict, List, Optional
14
+ from config import (
15
+ CACHE_RETRY_SECONDS,
16
+ CACHE_TTL_SECONDS,
17
+ DEBUG,
18
+ EXTERNAL_API_URL,
19
+ REQUEST_TIMEOUT,
20
+ )
21
+ from infrastructure.cache import AsyncTTLCache, CacheUnavailableError
22
 
23
  # === UUID detection ===
24
  UUID_PATTERN = re.compile(
 
66
  return None
67
 
68
 
69
+ _metrics_cache = AsyncTTLCache(CACHE_TTL_SECONDS, CACHE_RETRY_SECONDS)
70
+
71
+
72
+ async def fetch_metrics_cached(portfolio_id: str) -> Dict[str, Any]:
73
+ """Cached variant with cooldown on upstream failures."""
74
+
75
+ async def _loader() -> Dict[str, Any]:
76
+ data = await fetch_metrics_async(portfolio_id)
77
+ if not data:
78
+ raise CacheUnavailableError(
79
+ "Metrics temporarily unavailable from upstream API.",
80
+ CACHE_RETRY_SECONDS,
81
+ )
82
+ return data
83
+
84
+ return await _metrics_cache.get(portfolio_id, _loader)
85
+
86
+
87
  async def fetch_absolute_pnl_async(portfolio_id: str) -> Optional[List[Dict[str, Any]]]:
88
  """Fetch absolutePnL daily data."""
89
  url = f"{EXTERNAL_API_URL}/portfolio/get?portfolioId={portfolio_id}&extended=1&step=day"
presentation/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Presentation layer: UI components, charts, and styles."""
2
+
3
+ from . import components, styles
4
+
5
+ __all__ = ["components", "styles"]
presentation/components/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Individual UI components used by the Gradio interface."""
2
+
3
+ from .comparison_table import show_comparison_table
4
+ from .crypto_dashboard import build_crypto_dashboard
5
+ from .visual_comparison import build_price_chart, build_volatility_chart
6
+
7
+ __all__ = [
8
+ "show_comparison_table",
9
+ "build_crypto_dashboard",
10
+ "build_price_chart",
11
+ "build_volatility_chart",
12
+ ]
{core β†’ presentation/components}/comparison_table.py RENAMED
@@ -6,34 +6,90 @@ Purpose: Generates comparative DataFrame for two portfolios and an LLM commentar
6
  НазначСниС: создаёт ΡΡ€Π°Π²Π½ΠΈΡ‚Π΅Π»ΡŒΠ½ΡƒΡŽ Ρ‚Π°Π±Π»ΠΈΡ†Ρƒ ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊ Π΄Π²ΡƒΡ… ΠΏΠΎΡ€Ρ‚Ρ„Π΅Π»Π΅ΠΉ ΠΈ ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΉ LLM.
7
  """
8
 
9
- import pandas as pd
10
  import asyncio
11
- from services.output_api import fetch_metrics_async, extract_portfolio_id
12
- from services.llm_client import llm_service
 
 
 
 
 
 
 
13
  from prompts.system_prompts import COMPARISON_SYSTEM_PROMPT
14
 
15
 
16
  def show_comparison_table(portfolio_a: str, portfolio_b: str):
17
- """Public Gradio entry: returns both a DataFrame and LLM commentary."""
 
18
  pid_a = extract_portfolio_id(portfolio_a)
19
  pid_b = extract_portfolio_id(portfolio_b)
20
  if not pid_a or not pid_b:
21
- return "❌ Invalid portfolio IDs.", "No commentary available."
 
22
 
23
  try:
24
  df, commentary = asyncio.run(_build_comparison_with_comment(pid_a, pid_b))
25
  return df, commentary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  except Exception as e:
27
- return f"❌ Error building comparison table: {e}", "❌ LLM analysis failed."
 
 
 
 
 
 
 
 
 
 
 
28
 
29
 
30
  async def _build_comparison_with_comment(p1: str, p2: str):
31
- """Async helper: builds table and gets commentary."""
32
- m1 = await fetch_metrics_async(p1)
33
- m2 = await fetch_metrics_async(p2)
34
  if not m1 or not m2:
35
  raise ValueError("Metrics unavailable for one or both portfolios.")
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  all_keys = sorted(set(m1.keys()) | set(m2.keys()))
38
  rows = []
39
  for k in all_keys:
@@ -41,15 +97,29 @@ async def _build_comparison_with_comment(p1: str, p2: str):
41
  v2 = m2.get(k, 0)
42
  diff = v1 - v2
43
  symbol = "β–²" if diff > 0 else "β–Ό" if diff < 0 else "β€”"
44
- rows.append({
45
- "Metric": k,
46
- "Portfolio A": round(v1, 3),
47
- "Portfolio B": round(v2, 3),
48
- "Ξ” Difference": f"{symbol} {diff:+.3f}"
49
- })
50
- df = pd.DataFrame(rows, columns=["Metric", "Portfolio A", "Portfolio B", "Ξ” Difference"])
 
 
 
51
 
52
- # Generate LLM commentary
 
 
 
 
 
 
 
 
 
 
 
53
  summary = "\n".join(f"{r['Metric']}: {r['Ξ” Difference']}" for r in rows)
54
  prompt = (
55
  f"{COMPARISON_SYSTEM_PROMPT}\n"
@@ -57,7 +127,7 @@ async def _build_comparison_with_comment(p1: str, p2: str):
57
  f"Write your insights as a concise professional commentary."
58
  )
59
 
60
- commentary = ""
61
  for delta in llm_service.stream_chat(
62
  messages=[
63
  {"role": "system", "content": "You are an investment portfolio analyst."},
@@ -65,6 +135,5 @@ async def _build_comparison_with_comment(p1: str, p2: str):
65
  ],
66
  model="meta-llama/Meta-Llama-3.1-8B-Instruct",
67
  ):
68
- commentary += delta
69
-
70
- return df, commentary
 
6
  НазначСниС: создаёт ΡΡ€Π°Π²Π½ΠΈΡ‚Π΅Π»ΡŒΠ½ΡƒΡŽ Ρ‚Π°Π±Π»ΠΈΡ†Ρƒ ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊ Π΄Π²ΡƒΡ… ΠΏΠΎΡ€Ρ‚Ρ„Π΅Π»Π΅ΠΉ ΠΈ ΠΊΠΎΠΌΠΌΠ΅Π½Ρ‚Π°Ρ€ΠΈΠΉ LLM.
7
  """
8
 
 
9
  import asyncio
10
+
11
+ import pandas as pd
12
+
13
+ from infrastructure.cache import CacheUnavailableError
14
+ from infrastructure.llm_client import llm_service
15
+ from infrastructure.output_api import (
16
+ extract_portfolio_id,
17
+ fetch_metrics_cached,
18
+ )
19
  from prompts.system_prompts import COMPARISON_SYSTEM_PROMPT
20
 
21
 
22
  def show_comparison_table(portfolio_a: str, portfolio_b: str):
23
+ """Return the comparison DataFrame along with commentary."""
24
+
25
  pid_a = extract_portfolio_id(portfolio_a)
26
  pid_b = extract_portfolio_id(portfolio_b)
27
  if not pid_a or not pid_b:
28
+ message = "❌ Invalid portfolio IDs."
29
+ return _message_df(message), message
30
 
31
  try:
32
  df, commentary = asyncio.run(_build_comparison_with_comment(pid_a, pid_b))
33
  return df, commentary
34
+ except CacheUnavailableError as e:
35
+ wait = int(e.retry_in) + 1
36
+ message = f"⚠️ Metrics temporarily unavailable. Retry in ~{wait} seconds."
37
+ return _message_df(message), message
38
+ except Exception as e:
39
+ message = f"❌ Error building comparison table: {e}"
40
+ return _message_df(message), message
41
+
42
+
43
+ def stream_comparison_commentary(portfolio_a: str, portfolio_b: str):
44
+ """Stream the AI commentary using cached metrics."""
45
+
46
+ pid_a = extract_portfolio_id(portfolio_a)
47
+ pid_b = extract_portfolio_id(portfolio_b)
48
+ if not pid_a or not pid_b:
49
+ yield "❌ Invalid portfolio IDs."
50
+ return
51
+
52
+ yield "⏳ Working..."
53
+ try:
54
+ m1, m2 = asyncio.run(_fetch_metric_pair(pid_a, pid_b))
55
+ except CacheUnavailableError as e:
56
+ wait = int(e.retry_in) + 1
57
+ yield f"⚠️ Metrics temporarily unavailable. Retry in ~{wait} seconds."
58
+ return
59
  except Exception as e:
60
+ yield f"❌ Error collecting metrics: {e}"
61
+ return
62
+
63
+ if not m1 or not m2:
64
+ yield "❗ Metrics unavailable for one of the portfolios."
65
+ return
66
+
67
+ try:
68
+ for partial in _commentary_stream(_rows_from_metrics(m1, m2)):
69
+ yield partial
70
+ except Exception as e: # noqa: BLE001
71
+ yield f"❌ LLM analysis failed: {e}"
72
 
73
 
74
  async def _build_comparison_with_comment(p1: str, p2: str):
75
+ m1, m2 = await _fetch_metric_pair(p1, p2)
 
 
76
  if not m1 or not m2:
77
  raise ValueError("Metrics unavailable for one or both portfolios.")
78
 
79
+ rows = _rows_from_metrics(m1, m2)
80
+ df = pd.DataFrame(rows, columns=["Metric", "Portfolio A", "Portfolio B", "Ξ” Difference"])
81
+ commentary = _collect_commentary(rows)
82
+ return df, commentary
83
+
84
+
85
+ async def _fetch_metric_pair(p1: str, p2: str):
86
+ return await asyncio.gather(
87
+ fetch_metrics_cached(p1),
88
+ fetch_metrics_cached(p2),
89
+ )
90
+
91
+
92
+ def _rows_from_metrics(m1: dict, m2: dict):
93
  all_keys = sorted(set(m1.keys()) | set(m2.keys()))
94
  rows = []
95
  for k in all_keys:
 
97
  v2 = m2.get(k, 0)
98
  diff = v1 - v2
99
  symbol = "β–²" if diff > 0 else "β–Ό" if diff < 0 else "β€”"
100
+ rows.append(
101
+ {
102
+ "Metric": k,
103
+ "Portfolio A": round(v1, 3),
104
+ "Portfolio B": round(v2, 3),
105
+ "Ξ” Difference": f"{symbol} {diff:+.3f}",
106
+ }
107
+ )
108
+ return rows
109
+
110
 
111
+ def _message_df(message: str) -> pd.DataFrame:
112
+ return pd.DataFrame({"Message": [message]})
113
+
114
+
115
+ def _collect_commentary(rows: list[dict]) -> str:
116
+ commentary = ""
117
+ for partial in _commentary_stream(rows):
118
+ commentary = partial
119
+ return commentary
120
+
121
+
122
+ def _commentary_stream(rows: list[dict]):
123
  summary = "\n".join(f"{r['Metric']}: {r['Ξ” Difference']}" for r in rows)
124
  prompt = (
125
  f"{COMPARISON_SYSTEM_PROMPT}\n"
 
127
  f"Write your insights as a concise professional commentary."
128
  )
129
 
130
+ partial = ""
131
  for delta in llm_service.stream_chat(
132
  messages=[
133
  {"role": "system", "content": "You are an investment portfolio analyst."},
 
135
  ],
136
  model="meta-llama/Meta-Llama-3.1-8B-Instruct",
137
  ):
138
+ partial += delta
139
+ yield partial
 
{core β†’ presentation/components}/crypto_dashboard.py RENAMED
@@ -5,9 +5,11 @@ Crypto Dashboard β€” Plotly Edition (clean layout)
5
  β€’ Π±Π΅Π· глобального Markdown-Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ°
6
  """
7
  import requests
 
8
  import pandas as pd
9
  import plotly.express as px
10
- from services.llm_client import llm_service
 
11
 
12
 
13
  def fetch_coinlore_data(limit=100):
 
5
  β€’ Π±Π΅Π· глобального Markdown-Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ°
6
  """
7
  import requests
8
+
9
  import pandas as pd
10
  import plotly.express as px
11
+
12
+ from infrastructure.llm_client import llm_service
13
 
14
 
15
  def fetch_coinlore_data(limit=100):
{core β†’ presentation/components}/multi_charts.py RENAMED
File without changes
{core β†’ presentation/components}/visual_comparison.py RENAMED
@@ -7,26 +7,43 @@ import requests
7
  import pandas as pd
8
  import plotly.graph_objects as go
9
 
 
 
 
10
  COINGECKO_API = "https://api.coingecko.com/api/v3"
11
 
 
 
12
 
13
  def get_coin_history(coin_id: str, days: int = 180):
14
  """Fetch historical market data for given coin from CoinGecko API."""
15
- url = f"{COINGECKO_API}/coins/{coin_id}/market_chart?vs_currency=usd&days={days}"
16
- r = requests.get(url)
17
- r.raise_for_status()
18
- data = r.json()
19
- df = pd.DataFrame(data["prices"], columns=["timestamp", "price"])
20
- df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
21
- return df
 
 
 
22
 
23
 
24
  def build_price_chart(pair: tuple[str, str], days: int = 180):
25
  """Build comparative price chart for selected pair."""
26
  coin_a, coin_b = pair
27
 
28
- df_a = get_coin_history(coin_a, days)
29
- df_b = get_coin_history(coin_b, days)
 
 
 
 
 
 
 
 
 
30
 
31
  fig = go.Figure()
32
  fig.add_trace(go.Scatter(
@@ -59,8 +76,17 @@ def build_volatility_chart(pair: tuple[str, str], days: int = 180):
59
  """Build comparative volatility chart for selected pair."""
60
  coin_a, coin_b = pair
61
 
62
- df_a = get_coin_history(coin_a, days)
63
- df_b = get_coin_history(coin_b, days)
 
 
 
 
 
 
 
 
 
64
 
65
  df_a["returns"] = df_a["price"].pct_change() * 100
66
  df_b["returns"] = df_b["price"].pct_change() * 100
@@ -92,3 +118,37 @@ def build_volatility_chart(pair: tuple[str, str], days: int = 180):
92
  )
93
 
94
  return fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  import pandas as pd
8
  import plotly.graph_objects as go
9
 
10
+ from config import CACHE_RETRY_SECONDS, CACHE_TTL_SECONDS
11
+ from infrastructure.cache import CacheUnavailableError, TTLCache
12
+
13
  COINGECKO_API = "https://api.coingecko.com/api/v3"
14
 
15
+ _history_cache = TTLCache(CACHE_TTL_SECONDS, CACHE_RETRY_SECONDS)
16
+
17
 
18
  def get_coin_history(coin_id: str, days: int = 180):
19
  """Fetch historical market data for given coin from CoinGecko API."""
20
+ def _load():
21
+ url = f"{COINGECKO_API}/coins/{coin_id}/market_chart?vs_currency=usd&days={days}"
22
+ r = requests.get(url, timeout=20)
23
+ r.raise_for_status()
24
+ data = r.json()
25
+ df = pd.DataFrame(data["prices"], columns=["timestamp", "price"])
26
+ df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
27
+ return df
28
+
29
+ return _history_cache.get((coin_id, days), _load)
30
 
31
 
32
  def build_price_chart(pair: tuple[str, str], days: int = 180):
33
  """Build comparative price chart for selected pair."""
34
  coin_a, coin_b = pair
35
 
36
+ try:
37
+ df_a = get_coin_history(coin_a, days)
38
+ df_b = get_coin_history(coin_b, days)
39
+ except CacheUnavailableError as e:
40
+ wait = int(e.retry_in) + 1
41
+ return _error_figure(
42
+ "Price Comparison",
43
+ f"API cooling down. Retry in ~{wait} seconds.",
44
+ )
45
+ except Exception as e: # noqa: BLE001
46
+ return _error_figure("Price Comparison", f"Failed to load data: {e}")
47
 
48
  fig = go.Figure()
49
  fig.add_trace(go.Scatter(
 
76
  """Build comparative volatility chart for selected pair."""
77
  coin_a, coin_b = pair
78
 
79
+ try:
80
+ df_a = get_coin_history(coin_a, days)
81
+ df_b = get_coin_history(coin_b, days)
82
+ except CacheUnavailableError as e:
83
+ wait = int(e.retry_in) + 1
84
+ return _error_figure(
85
+ "Volatility Comparison",
86
+ f"API cooling down. Retry in ~{wait} seconds.",
87
+ )
88
+ except Exception as e: # noqa: BLE001
89
+ return _error_figure("Volatility Comparison", f"Failed to load data: {e}")
90
 
91
  df_a["returns"] = df_a["price"].pct_change() * 100
92
  df_b["returns"] = df_b["price"].pct_change() * 100
 
118
  )
119
 
120
  return fig
121
+
122
+
123
+ def preload_pairs(pairs: list[tuple[str, str]], days: int = 180) -> None:
124
+ """Warm up the cache for all coins involved in the provided pairs."""
125
+
126
+ coins = {coin for pair in pairs for coin in pair}
127
+ for coin in coins:
128
+ try:
129
+ get_coin_history(coin, days)
130
+ except CacheUnavailableError:
131
+ continue
132
+ except Exception:
133
+ continue
134
+
135
+
136
+ def _error_figure(title: str, message: str):
137
+ fig = go.Figure()
138
+ fig.add_annotation(
139
+ text=message,
140
+ showarrow=False,
141
+ font=dict(color="#ff6b6b", size=16),
142
+ xref="paper",
143
+ yref="paper",
144
+ x=0.5,
145
+ y=0.5,
146
+ )
147
+ fig.update_layout(
148
+ template="plotly_dark",
149
+ title=title,
150
+ xaxis=dict(visible=False),
151
+ yaxis=dict(visible=False),
152
+ height=420,
153
+ )
154
+ return fig
{core β†’ presentation/components}/visualization.py RENAMED
File without changes
presentation/styles/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Static style assets for the presentation layer."""
2
+
3
+ __all__ = [
4
+ "themes",
5
+ ]
presentation/styles/themes/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """Theme CSS assets for the presentation layer."""
2
+
3
+ __all__: list[str] = []
{core/styles β†’ presentation/styles/themes}/base.css RENAMED
File without changes
{core/styles β†’ presentation/styles/themes}/crypto_dashboard.css RENAMED
File without changes
{core/styles β†’ presentation/styles/themes}/multi_charts.css RENAMED
File without changes
{core β†’ presentation/styles}/ui_style.css RENAMED
File without changes
services/__init__.py DELETED
@@ -1,2 +0,0 @@
1
- # __init__.py
2
- # Marks this directory as a Python package.