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

Improve crypto summary context and stabilize metrics fetch

Browse files
Files changed (33) hide show
  1. README.md +11 -0
  2. app.py +74 -25
  3. application/__init__.py +13 -0
  4. core/chat.py β†’ application/chat_assistant.py +10 -4
  5. core/metrics.py β†’ application/metrics_table.py +17 -9
  6. core/analyzer.py β†’ application/portfolio_analyzer.py +13 -8
  7. core/comparer.py β†’ application/portfolio_comparer.py +14 -9
  8. config.py +4 -0
  9. core/__init__.py +13 -2
  10. core/comparison_table.py +0 -70
  11. domain/__init__.py +3 -0
  12. infrastructure/__init__.py +20 -0
  13. infrastructure/cache.py +167 -0
  14. {services β†’ infrastructure}/llm_client.py +0 -0
  15. infrastructure/market_data/__init__.py +5 -0
  16. core/data_binance.py β†’ infrastructure/market_data/binance.py +0 -0
  17. core/data_coinlore.py β†’ infrastructure/market_data/coinlore.py +0 -0
  18. core/data_yfinance.py β†’ infrastructure/market_data/yfinance.py +0 -0
  19. {services β†’ infrastructure}/output_api.py +69 -21
  20. presentation/__init__.py +5 -0
  21. presentation/components/__init__.py +12 -0
  22. presentation/components/comparison_table.py +97 -0
  23. {core β†’ presentation/components}/crypto_dashboard.py +139 -13
  24. {core β†’ presentation/components}/multi_charts.py +0 -0
  25. {core β†’ presentation/components}/visual_comparison.py +77 -11
  26. {core β†’ presentation/components}/visualization.py +0 -0
  27. presentation/styles/__init__.py +5 -0
  28. presentation/styles/themes/__init__.py +3 -0
  29. {core/styles β†’ presentation/styles/themes}/base.css +10 -0
  30. {core/styles β†’ presentation/styles/themes}/crypto_dashboard.css +8 -1
  31. {core/styles β†’ presentation/styles/themes}/multi_charts.css +0 -0
  32. {core β†’ presentation/styles}/ui_style.css +0 -0
  33. 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,33 +47,73 @@ 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"):
57
- chat_in = gr.Textbox(label="Ask about investments or analysis")
58
- chat_btn = gr.Button("Send Question", variant="primary")
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 +145,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
+ interactive=False,
55
+ )
56
+ analyze_btn.click(
57
+ fn=analyzer.run,
58
+ inputs=portfolio_input,
59
+ outputs=analyze_out,
60
+ show_progress="minimal",
61
+ )
62
 
63
  # --- Comparison Table ---
64
  with gr.TabItem("Comparison Table"):
65
+ with gr.Row():
66
+ pid_a = gr.Textbox(
67
+ label="Portfolio A",
68
+ value="3852a354-e66e-4bc5-97e9-55124e31e687",
69
+ scale=1,
70
+ )
71
+ pid_b = gr.Textbox(
72
+ label="Portfolio B",
73
+ value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb",
74
+ scale=1,
75
+ )
76
  compare_btn = gr.Button("Load Comparison", variant="primary")
77
+ comp_table = gr.Dataframe(
78
+ label="Comparative Metrics",
79
+ wrap=True,
80
+ elem_id="comparison_table",
81
+ )
82
+ comp_comment = gr.Textbox(
83
+ label="AI Commentary",
84
+ lines=14,
85
+ elem_id="llm_comment_box",
86
+ interactive=False,
87
+ )
88
+ compare_btn.click(
89
+ fn=show_comparison_table,
90
+ inputs=[pid_a, pid_b],
91
+ outputs=[comp_table, comp_comment],
92
+ show_progress="minimal",
93
+ )
94
 
95
  # --- Metrics Table ---
96
  with gr.TabItem("Metrics Table"):
97
+ metrics_in = gr.Textbox(
98
+ label="Portfolio ID",
99
+ value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb",
100
+ )
101
  metrics_btn = gr.Button("Load Metrics", variant="primary")
102
  metrics_out = gr.Dataframe(label="Portfolio Metrics", wrap=True)
103
  metrics_btn.click(fn=show_metrics_table, inputs=metrics_in, outputs=metrics_out)
104
 
105
+ # --- Assistant ---
106
+ with gr.TabItem("Assistant"):
107
+ chat_in = gr.Textbox(label="Ask about investments or analysis")
108
+ chat_btn = gr.Button("Send Question", variant="primary")
109
+ chat_out = gr.Textbox(label="AI Response", lines=8, interactive=False)
110
+ chat_btn.click(
111
+ fn=chatbot.run,
112
+ inputs=chat_in,
113
+ outputs=chat_out,
114
+ show_progress="minimal",
115
+ )
116
+
117
  # --- Visual Comparison (Interactive Plotly Edition) ---
118
  with gr.TabItem("Visual Comparison"):
119
  gr.Markdown("### πŸ“Š Market Pair Comparison β€” Interactive Plotly Edition")
 
145
  pair_selector.change(fn=update_visuals, inputs=pair_selector, outputs=[price_plot, vol_plot])
146
 
147
  def init_visuals():
148
+ preload_pairs(available_pairs)
149
  return update_visuals("Bitcoin vs Ethereum")
150
 
151
  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
 
@@ -20,16 +21,21 @@ class ChatAssistant:
20
 
21
  def run(self, user_input: str) -> Generator[str, None, None]:
22
  """Stream chat responses."""
 
 
 
 
 
 
23
  messages = [
24
  {"role": "system", "content": GENERAL_CONTEXT},
25
  {"role": "user", "content": user_input},
26
  ]
27
 
28
  try:
29
- partial = ""
30
  for delta in self.llm.stream_chat(messages=messages, model=self.model_name):
31
  partial += delta
32
  yield partial
33
 
34
- except Exception as e:
35
- yield f"❌ Ошибка ΠΏΡ€ΠΈ Π³Π΅Π½Π΅Ρ€Π°Ρ†ΠΈΠΈ ΠΎΡ‚Π²Π΅Ρ‚Π°: {e}"
 
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
 
 
21
 
22
  def run(self, user_input: str) -> Generator[str, None, None]:
23
  """Stream chat responses."""
24
+ if not user_input or not user_input.strip():
25
+ yield "❗ Please enter a question for the assistant."
26
+ return
27
+
28
+ yield "⏳ Working..."
29
+ partial = ""
30
  messages = [
31
  {"role": "system", "content": GENERAL_CONTEXT},
32
  {"role": "user", "content": user_input},
33
  ]
34
 
35
  try:
 
36
  for delta in self.llm.stream_chat(messages=messages, model=self.model_name):
37
  partial += delta
38
  yield partial
39
 
40
+ except Exception:
41
+ yield "❌ Assistant is unavailable right now. Please try again later."
core/metrics.py β†’ application/metrics_table.py RENAMED
@@ -7,28 +7,36 @@ Purpose: Provides async utilities to fetch and display portfolio metrics as a Da
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
 
 
 
 
 
7
  """
8
 
9
  import pandas as pd
10
+
11
+ from infrastructure.cache import CacheUnavailableError
12
+ from infrastructure.output_api import extract_portfolio_id, fetch_metrics_cached
13
 
14
 
15
  def show_metrics_table(portfolio_input: str):
16
  """Fetch portfolio metrics and return them as a DataFrame for Gradio."""
17
  pid = extract_portfolio_id(portfolio_input)
18
  if not pid:
19
+ return _message_df("❌ Invalid portfolioId format.")
20
 
21
  try:
22
+ df = _get_metrics_df(pid)
23
  return df
24
+ except CacheUnavailableError as e:
25
+ wait = int(e.retry_in) + 1
26
+ return _message_df(f"⚠️ Metrics API cooling down. Retry in ~{wait} seconds.")
27
+ except Exception:
28
+ return _message_df("❌ Error fetching metrics. Please try again later.")
29
 
30
 
31
+ def _get_metrics_df(portfolio_id: str) -> pd.DataFrame:
32
+ """Internal helper to get metrics with caching."""
33
+ metrics = fetch_metrics_cached(portfolio_id)
34
  if not metrics:
35
  raise ValueError("No metrics found for given portfolio.")
36
 
37
  df = pd.DataFrame(list(metrics.items()), columns=["Metric", "Value"])
38
  return df
39
+
40
+
41
+ def _message_df(message: str) -> pd.DataFrame:
42
+ return pd.DataFrame({"Message": [message]})
core/analyzer.py β†’ application/portfolio_analyzer.py RENAMED
@@ -6,10 +6,11 @@ Purpose: Handles single-portfolio analysis using LLM. Fetches metrics, builds pr
6
  НазначСниС: Π°Π½Π°Π»ΠΈΠ· ΠΎΠ΄Π½ΠΎΠ³ΠΎ инвСстиционного портфСля с использованиСм LLM. ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊΠΈ, Ρ„ΠΎΡ€ΠΌΠΈΡ€ΡƒΠ΅Ρ‚ ΠΏΡ€ΠΎΠΌΠΏΡ‚, Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ ΠΏΠΎΡ‚ΠΎΠΊΠΎΠ²Ρ‹ΠΉ ΠΎΡ‚Π²Π΅Ρ‚.
7
  """
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,9 +31,13 @@ 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
37
 
38
  if not metrics:
@@ -53,5 +58,5 @@ class PortfolioAnalyzer:
53
  partial += delta
54
  yield partial
55
 
56
- except Exception as e:
57
- yield f"❌ LLM's error: {e}"
 
6
  НазначСниС: Π°Π½Π°Π»ΠΈΠ· ΠΎΠ΄Π½ΠΎΠ³ΠΎ инвСстиционного портфСля с использованиСм LLM. ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊΠΈ, Ρ„ΠΎΡ€ΠΌΠΈΡ€ΡƒΠ΅Ρ‚ ΠΏΡ€ΠΎΠΌΠΏΡ‚, Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ ΠΏΠΎΡ‚ΠΎΠΊΠΎΠ²Ρ‹ΠΉ ΠΎΡ‚Π²Π΅Ρ‚.
7
  """
8
 
 
9
  from typing import Generator
10
+
11
+ from infrastructure.cache import CacheUnavailableError
12
+ from infrastructure.output_api import extract_portfolio_id, fetch_metrics_cached
13
+ from infrastructure.llm_client import llm_service
14
  from prompts.system_prompts import ANALYSIS_SYSTEM_PROMPT
15
  from prompts.reference_templates import REFERENCE_PROMPT
16
 
 
31
 
32
  yield "⏳ Working..."
33
  try:
34
+ metrics = fetch_metrics_cached(portfolio_id)
35
+ except CacheUnavailableError as e:
36
+ wait = int(e.retry_in) + 1
37
+ yield f"⚠️ API temporarily unavailable. Please retry in ~{wait} seconds."
38
+ return
39
+ except Exception:
40
+ yield "❌ Failed to collect metrics. Please try again later."
41
  return
42
 
43
  if not metrics:
 
58
  partial += delta
59
  yield partial
60
 
61
+ except Exception:
62
+ yield "❌ LLM is unavailable right now. Please try again later."
core/comparer.py β†’ application/portfolio_comparer.py RENAMED
@@ -6,10 +6,11 @@ Purpose: Compares two portfolios using LLM. Fetches metrics for both and builds
6
  НазначСниС: сравнСниС Π΄Π²ΡƒΡ… инвСстиционных ΠΏΠΎΡ€Ρ‚Ρ„Π΅Π»Π΅ΠΉ с ΠΏΠΎΠΌΠΎΡ‰ΡŒΡŽ LLM. ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊΠΈ ΠΎΠ±ΠΎΠΈΡ… ΠΏΠΎΡ€Ρ‚Ρ„Π΅Π»Π΅ΠΉ, Ρ„ΠΎΡ€ΠΌΠΈΡ€ΡƒΠ΅Ρ‚ ΠΏΡ€ΠΎΠΌΠΏΡ‚ ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ ΠΏΠΎΡ‚ΠΎΠΊΠΎΠ²Ρ‹ΠΉ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚.
7
  """
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,10 +36,14 @@ 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
43
 
44
  if not m1 or not m2:
@@ -66,5 +71,5 @@ class PortfolioComparer:
66
  partial += delta
67
  yield partial
68
 
69
- except Exception as e:
70
- yield f"❌ Ошибка ΠΏΡ€ΠΈ сравнСнии ΠΏΠΎΡ€Ρ‚Ρ„Π΅Π»Π΅ΠΉ Ρ‡Π΅Ρ€Π΅Π· LLM: {e}"
 
6
  НазначСниС: сравнСниС Π΄Π²ΡƒΡ… инвСстиционных ΠΏΠΎΡ€Ρ‚Ρ„Π΅Π»Π΅ΠΉ с ΠΏΠΎΠΌΠΎΡ‰ΡŒΡŽ LLM. ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊΠΈ ΠΎΠ±ΠΎΠΈΡ… ΠΏΠΎΡ€Ρ‚Ρ„Π΅Π»Π΅ΠΉ, Ρ„ΠΎΡ€ΠΌΠΈΡ€ΡƒΠ΅Ρ‚ ΠΏΡ€ΠΎΠΌΠΏΡ‚ ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ ΠΏΠΎΡ‚ΠΎΠΊΠΎΠ²Ρ‹ΠΉ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚.
7
  """
8
 
 
9
  from typing import Generator
10
+
11
+ from infrastructure.cache import CacheUnavailableError
12
+ from infrastructure.output_api import extract_portfolio_id, fetch_metrics_cached
13
+ from infrastructure.llm_client import llm_service
14
  from prompts.system_prompts import COMPARISON_SYSTEM_PROMPT
15
  from prompts.reference_templates import REFERENCE_COMPARISON_PROMPT
16
 
 
36
 
37
  yield "⏳ Working..."
38
  try:
39
+ m1 = fetch_metrics_cached(id1)
40
+ m2 = fetch_metrics_cached(id2)
41
+ except CacheUnavailableError as e:
42
+ wait = int(e.retry_in) + 1
43
+ yield f"⚠️ API temporarily unavailable. Retry in ~{wait} seconds."
44
+ return
45
+ except Exception:
46
+ yield "❌ Failed to collect comparison data. Please try again later."
47
  return
48
 
49
  if not m1 or not m2:
 
71
  partial += delta
72
  yield partial
73
 
74
+ except Exception:
75
+ yield "❌ LLM comparison is unavailable right now. Please try again later."
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
+ ]
core/comparison_table.py DELETED
@@ -1,70 +0,0 @@
1
- """
2
- πŸ‡¬πŸ‡§ Module: comparison_table.py
3
- Purpose: Generates comparative DataFrame for two portfolios and an LLM commentary.
4
-
5
- πŸ‡·πŸ‡Ί ΠœΠΎΠ΄ΡƒΠ»ΡŒ: comparison_table.py
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:
40
- v1 = m1.get(k, 0)
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"
56
- f"Compare and explain the differences between Portfolio A and B:\n{summary}\n"
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."},
64
- {"role": "user", "content": prompt},
65
- ],
66
- model="meta-llama/Meta-Llama-3.1-8B-Instruct",
67
- ):
68
- commentary += delta
69
-
70
- return df, commentary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
@@ -1,17 +1,17 @@
1
- """
2
- πŸ‡¬πŸ‡§ Module: api_client.py
3
- Purpose: Provides async API client for external portfolio analytics service.
4
- Handles fetching metrics, alphaBTC data, and other portfolio information.
5
-
6
- πŸ‡·πŸ‡Ί ΠœΠΎΠ΄ΡƒΠ»ΡŒ: api_client.py
7
- НазначСниС: асинхронный ΠΊΠ»ΠΈΠ΅Π½Ρ‚ для внСшнСго API Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠΈ ΠΏΠΎΡ€Ρ‚Ρ„Π΅Π»Π΅ΠΉ,
8
- Π²Ρ‹ΠΏΠΎΠ»Π½ΡΡŽΡ‰ΠΈΠΉ запросы ΠΊ ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊΠ°ΠΌ, Π΄Π°Π½Π½Ρ‹ΠΌ alphaBTC ΠΈ Π΄Ρ€ΡƒΠ³ΠΎΠΉ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΠΈ ΠΎ портфСлях.
9
- """
10
 
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(
@@ -36,20 +36,24 @@ async def _get_json(url: str) -> Dict[str, Any]:
36
  return r.json()
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  async def fetch_metrics_async(portfolio_id: str) -> Optional[Dict[str, Any]]:
40
- """Fetch portfolio metrics (extended data)."""
41
  url = f"{EXTERNAL_API_URL}/portfolio/get?portfolioId={portfolio_id}&extended=1"
42
  try:
43
  data = await _get_json(url)
44
- extended = data.get("data", {}).get("extended", {})
45
- result = {}
46
- for k, v in extended.items():
47
- if isinstance(v, (int, float)):
48
- # Convert some fields to percentages for readability
49
- if k in {"cagr", "alphaRatio", "volatility", "maxDD"}:
50
- result[k] = v * 100
51
- else:
52
- result[k] = v
53
  if DEBUG:
54
  print(f"[DEBUG] Metrics fetched for {portfolio_id}: {result}")
55
  return result
@@ -59,6 +63,50 @@ 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"
 
1
+ """Client helpers for the external portfolio analytics API."""
 
 
 
 
 
 
 
 
2
 
3
  import re
 
4
  from typing import Any, Dict, List, Optional
5
+
6
+ import httpx
7
+ from config import (
8
+ CACHE_RETRY_SECONDS,
9
+ CACHE_TTL_SECONDS,
10
+ DEBUG,
11
+ EXTERNAL_API_URL,
12
+ REQUEST_TIMEOUT,
13
+ )
14
+ from infrastructure.cache import CacheUnavailableError, TTLCache
15
 
16
  # === UUID detection ===
17
  UUID_PATTERN = re.compile(
 
36
  return r.json()
37
 
38
 
39
+ def _parse_metrics(payload: Dict[str, Any]) -> Dict[str, Any]:
40
+ extended = payload.get("data", {}).get("extended", {})
41
+ result: Dict[str, Any] = {}
42
+ for k, v in extended.items():
43
+ if isinstance(v, (int, float)):
44
+ if k in {"cagr", "alphaRatio", "volatility", "maxDD"}:
45
+ result[k] = v * 100
46
+ else:
47
+ result[k] = v
48
+ return result
49
+
50
+
51
  async def fetch_metrics_async(portfolio_id: str) -> Optional[Dict[str, Any]]:
52
+ """Fetch portfolio metrics (extended data) asynchronously."""
53
  url = f"{EXTERNAL_API_URL}/portfolio/get?portfolioId={portfolio_id}&extended=1"
54
  try:
55
  data = await _get_json(url)
56
+ result = _parse_metrics(data)
 
 
 
 
 
 
 
 
57
  if DEBUG:
58
  print(f"[DEBUG] Metrics fetched for {portfolio_id}: {result}")
59
  return result
 
63
  return None
64
 
65
 
66
+ def _get_json_sync(url: str) -> Dict[str, Any]:
67
+ """Synchronous helper mirroring :func:`_get_json`."""
68
+ if DEBUG:
69
+ print(f"[DEBUG] Requesting URL (sync): {url}")
70
+
71
+ with httpx.Client(timeout=REQUEST_TIMEOUT) as client:
72
+ r = client.get(url, headers={"User-Agent": "Mozilla/5.0", "Accept": "application/json"})
73
+ r.raise_for_status()
74
+ return r.json()
75
+
76
+
77
+ def fetch_metrics(portfolio_id: str) -> Optional[Dict[str, Any]]:
78
+ """Synchronous helper to fetch metrics for caching loaders."""
79
+ url = f"{EXTERNAL_API_URL}/portfolio/get?portfolioId={portfolio_id}&extended=1"
80
+ try:
81
+ data = _get_json_sync(url)
82
+ result = _parse_metrics(data)
83
+ if DEBUG:
84
+ print(f"[DEBUG] Metrics fetched (sync) for {portfolio_id}: {result}")
85
+ return result
86
+ except Exception as e:
87
+ if DEBUG:
88
+ print(f"[ERROR] fetch_metrics: {e}")
89
+ return None
90
+
91
+
92
+ _metrics_cache = TTLCache(CACHE_TTL_SECONDS, CACHE_RETRY_SECONDS)
93
+
94
+
95
+ def fetch_metrics_cached(portfolio_id: str) -> Dict[str, Any]:
96
+ """Cached variant with cooldown on upstream failures."""
97
+
98
+ def _loader() -> Dict[str, Any]:
99
+ data = fetch_metrics(portfolio_id)
100
+ if not data:
101
+ raise CacheUnavailableError(
102
+ "Metrics temporarily unavailable from upstream API.",
103
+ CACHE_RETRY_SECONDS,
104
+ )
105
+ return data
106
+
107
+ return _metrics_cache.get(portfolio_id, _loader)
108
+
109
+
110
  async def fetch_absolute_pnl_async(portfolio_id: str) -> Optional[List[Dict[str, Any]]]:
111
  """Fetch absolutePnL daily data."""
112
  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
+ ]
presentation/components/comparison_table.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate comparative tables and commentary for two portfolios."""
2
+
3
+ from typing import Dict, List
4
+
5
+ import pandas as pd
6
+
7
+ from infrastructure.cache import CacheUnavailableError
8
+ from infrastructure.llm_client import llm_service
9
+ from infrastructure.output_api import extract_portfolio_id, fetch_metrics_cached
10
+ from prompts.system_prompts import COMPARISON_SYSTEM_PROMPT
11
+
12
+
13
+ def show_comparison_table(portfolio_a: str, portfolio_b: str):
14
+ """Return the comparison DataFrame along with commentary."""
15
+
16
+ pid_a = extract_portfolio_id(portfolio_a)
17
+ pid_b = extract_portfolio_id(portfolio_b)
18
+ if not pid_a or not pid_b:
19
+ message = "❌ Invalid portfolio IDs."
20
+ return _message_df(message), message
21
+
22
+ try:
23
+ df, commentary = _build_comparison_with_comment(pid_a, pid_b)
24
+ return df, commentary
25
+ except CacheUnavailableError as e:
26
+ wait = int(e.retry_in) + 1
27
+ message = f"⚠️ Metrics temporarily unavailable. Retry in ~{wait} seconds."
28
+ return _message_df(message), message
29
+ except Exception:
30
+ message = "❌ Unable to build comparison right now. Please try again later."
31
+ return _message_df(message), message
32
+
33
+
34
+ def _build_comparison_with_comment(p1: str, p2: str):
35
+ m1 = fetch_metrics_cached(p1)
36
+ m2 = fetch_metrics_cached(p2)
37
+ if not m1 or not m2:
38
+ raise ValueError("Metrics unavailable for one or both portfolios.")
39
+
40
+ rows = _rows_from_metrics(m1, m2)
41
+ df = pd.DataFrame(rows, columns=["Ξ” Difference", "Portfolio A", "Portfolio B", "Metric"])
42
+ commentary = _collect_commentary(rows)
43
+ return df, commentary
44
+
45
+
46
+ def _rows_from_metrics(m1: Dict, m2: Dict) -> List[Dict]:
47
+ all_keys = sorted(set(m1.keys()) | set(m2.keys()))
48
+ rows: List[Dict] = []
49
+ for k in all_keys:
50
+ v1 = m1.get(k, 0)
51
+ v2 = m2.get(k, 0)
52
+ diff = v1 - v2
53
+ symbol = "β–²" if diff > 0 else "β–Ό" if diff < 0 else "β€”"
54
+ rows.append(
55
+ {
56
+ "Ξ” Difference": f"{symbol} {diff:+.3f}",
57
+ "Portfolio A": round(v1, 3),
58
+ "Portfolio B": round(v2, 3),
59
+ "Metric": k,
60
+ }
61
+ )
62
+ return rows
63
+
64
+
65
+ def _message_df(message: str) -> pd.DataFrame:
66
+ return pd.DataFrame({"Message": [message]})
67
+
68
+
69
+ def _collect_commentary(rows: List[Dict]) -> str:
70
+ commentary = ""
71
+ for partial in _commentary_stream(rows):
72
+ commentary = partial
73
+ return commentary
74
+
75
+
76
+ def _commentary_stream(rows: List[Dict]):
77
+ summary = "\n".join(f"{r['Metric']}: {r['Ξ” Difference']}" for r in rows)
78
+ prompt = (
79
+ f"{COMPARISON_SYSTEM_PROMPT}\n"
80
+ f"Compare and explain the differences between Portfolio A and B:\n{summary}\n"
81
+ f"Write your insights as a concise professional commentary."
82
+ )
83
+
84
+ partial = ""
85
+ try:
86
+ iterator = llm_service.stream_chat(
87
+ messages=[
88
+ {"role": "system", "content": "You are an investment portfolio analyst."},
89
+ {"role": "user", "content": prompt},
90
+ ],
91
+ model="meta-llama/Meta-Llama-3.1-8B-Instruct",
92
+ )
93
+ for delta in iterator:
94
+ partial += delta
95
+ yield partial
96
+ except Exception:
97
+ yield "❌ LLM analysis is unavailable right now. Please try again later."
{core β†’ presentation/components}/crypto_dashboard.py RENAMED
@@ -5,19 +5,57 @@ 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):
14
  url = "https://api.coinlore.net/api/tickers/"
15
- data = requests.get(url).json()["data"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  df = pd.DataFrame(data)
17
- for col in ["price_usd", "market_cap_usd", "volume24",
18
- "percent_change_1h", "percent_change_24h", "percent_change_7d"]:
 
 
 
 
 
 
19
  df[col] = pd.to_numeric(df[col], errors="coerce")
20
- return df.head(limit)
 
 
 
 
 
 
 
21
 
22
 
23
  def _kpi_line(df) -> str:
@@ -40,7 +78,27 @@ def _kpi_line(df) -> str:
40
 
41
 
42
  def build_crypto_dashboard(top_n=50):
43
- df = fetch_coinlore_data(top_n)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
  # === Treemap ===
46
  fig_treemap = px.treemap(
@@ -109,18 +167,86 @@ def build_crypto_dashboard(top_n=50):
109
 
110
 
111
  def _ai_summary(df):
 
112
  leaders = df.sort_values("percent_change_24h", ascending=False).head(3)["symbol"].tolist()
113
  laggards = df.sort_values("percent_change_24h").head(3)["symbol"].tolist()
114
- prompt = f"""
115
- Summarize today's crypto market based on Coinlore data.
116
- Top gainers: {', '.join(leaders)}.
117
- Top losers: {', '.join(laggards)}.
118
- Include: overall sentiment, volatility/liquidity, short-term outlook.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  """
 
120
  text = ""
121
  for delta in llm_service.stream_chat(
122
- messages=[{"role": "user", "content": prompt}],
 
 
 
123
  model="meta-llama/Meta-Llama-3.1-8B-Instruct",
124
  ):
125
  text += delta
126
  return text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  β€’ Π±Π΅Π· глобального Markdown-Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ°
6
  """
7
  import requests
8
+
9
  import pandas as pd
10
  import plotly.express as px
11
+ import plotly.graph_objects as go
12
+
13
+ from config import CACHE_RETRY_SECONDS, CACHE_TTL_SECONDS
14
+ from infrastructure.cache import CacheUnavailableError, TTLCache
15
+ from infrastructure.llm_client import llm_service
16
+
17
+
18
+ _coinlore_cache = TTLCache(CACHE_TTL_SECONDS, CACHE_RETRY_SECONDS)
19
 
20
 
21
+ def _load_coinlore() -> pd.DataFrame:
22
  url = "https://api.coinlore.net/api/tickers/"
23
+ try:
24
+ response = requests.get(url, timeout=20)
25
+ response.raise_for_status()
26
+ payload = response.json()
27
+ data = payload.get("data")
28
+ if not isinstance(data, list):
29
+ raise ValueError("Unexpected Coinlore payload structure")
30
+ except requests.RequestException as exc: # noqa: PERF203 - propagate meaningful message
31
+ raise CacheUnavailableError(
32
+ "Coinlore API request failed.",
33
+ CACHE_RETRY_SECONDS,
34
+ ) from exc
35
+ except ValueError as exc:
36
+ raise CacheUnavailableError(
37
+ "Coinlore API returned unexpected response.",
38
+ CACHE_RETRY_SECONDS,
39
+ ) from exc
40
+
41
  df = pd.DataFrame(data)
42
+ for col in [
43
+ "price_usd",
44
+ "market_cap_usd",
45
+ "volume24",
46
+ "percent_change_1h",
47
+ "percent_change_24h",
48
+ "percent_change_7d",
49
+ ]:
50
  df[col] = pd.to_numeric(df[col], errors="coerce")
51
+ return df
52
+
53
+
54
+ def fetch_coinlore_data(limit: int = 100) -> pd.DataFrame:
55
+ """Return cached Coinlore data limited to the requested number of rows."""
56
+
57
+ base = _coinlore_cache.get("coinlore", _load_coinlore)
58
+ return base.head(limit).copy()
59
 
60
 
61
  def _kpi_line(df) -> str:
 
78
 
79
 
80
  def build_crypto_dashboard(top_n=50):
81
+ try:
82
+ df = fetch_coinlore_data(top_n)
83
+ except CacheUnavailableError as e:
84
+ wait = int(e.retry_in) + 1
85
+ message = f"⚠️ Coinlore API cooling down. Retry in ~{wait} seconds."
86
+ return (
87
+ _error_figure("Market Composition", message),
88
+ _error_figure("Top Movers", message),
89
+ _error_figure("Market Cap vs Volume", message),
90
+ message,
91
+ message,
92
+ )
93
+ except Exception: # noqa: BLE001 - surface unexpected failures
94
+ message = "❌ Failed to load market data. Please try again later."
95
+ return (
96
+ _error_figure("Market Composition", message),
97
+ _error_figure("Top Movers", message),
98
+ _error_figure("Market Cap vs Volume", message),
99
+ message,
100
+ message,
101
+ )
102
 
103
  # === Treemap ===
104
  fig_treemap = px.treemap(
 
167
 
168
 
169
  def _ai_summary(df):
170
+ timestamp = pd.Timestamp.utcnow().strftime("%Y-%m-%d %H:%M UTC")
171
  leaders = df.sort_values("percent_change_24h", ascending=False).head(3)["symbol"].tolist()
172
  laggards = df.sort_values("percent_change_24h").head(3)["symbol"].tolist()
173
+
174
+ total_cap = float(df["market_cap_usd"].sum()) if not df.empty else 0.0
175
+ total_volume = float(df["volume24"].sum()) if not df.empty else 0.0
176
+ btc_cap = float(df.loc[df["symbol"] == "BTC", "market_cap_usd"].sum()) if total_cap else 0.0
177
+ btc_dominance = (btc_cap / total_cap * 100) if total_cap else 0.0
178
+
179
+ snapshot_rows = (
180
+ df.sort_values("market_cap_usd", ascending=False)
181
+ .head(12)
182
+ [["symbol", "price_usd", "percent_change_24h", "percent_change_7d", "volume24"]]
183
+ )
184
+ lines = []
185
+ for row in snapshot_rows.itertuples(index=False):
186
+ lines.append(
187
+ (
188
+ f"{row.symbol}: price ${row.price_usd:,.2f}, "
189
+ f"24h {row.percent_change_24h:+.2f}%, "
190
+ f"7d {row.percent_change_7d:+.2f}%, "
191
+ f"24h volume ${row.volume24:,.0f}"
192
+ )
193
+ )
194
+ snapshot_text = "\n".join(lines)
195
+
196
+ system_prompt = (
197
+ "You are a crypto market strategist receiving a fresh Coinlore snapshot. "
198
+ "Use only the provided metrics to deliver an actionable analysis. "
199
+ "Do not mention training cutoffs or missing live accessβ€”assume the snapshot reflects the current market."
200
+ )
201
+ user_prompt = f"""
202
+ Coinlore snapshot captured at {timestamp}.
203
+ Aggregate totals:
204
+ - Total market cap (tracked set): ${total_cap:,.0f}
205
+ - 24h traded volume: ${total_volume:,.0f}
206
+ - BTC dominance: {btc_dominance:.2f}%
207
+
208
+ Key movers by 24h change:
209
+ {snapshot_text or 'No data available.'}
210
+
211
+ Top gainers (24h): {', '.join(leaders) if leaders else 'n/a'}
212
+ Top laggards (24h): {', '.join(laggards) if laggards else 'n/a'}
213
+
214
+ Provide:
215
+ 1. Market sentiment and breadth.
216
+ 2. Liquidity and volatility observations.
217
+ 3. Short-term outlook and immediate risks, grounded in this snapshot.
218
  """
219
+
220
  text = ""
221
  for delta in llm_service.stream_chat(
222
+ messages=[
223
+ {"role": "system", "content": system_prompt},
224
+ {"role": "user", "content": user_prompt},
225
+ ],
226
  model="meta-llama/Meta-Llama-3.1-8B-Instruct",
227
  ):
228
  text += delta
229
  return text
230
+
231
+
232
+ def _error_figure(title: str, message: str) -> go.Figure:
233
+ fig = go.Figure()
234
+ fig.add_annotation(
235
+ text=message,
236
+ showarrow=False,
237
+ font=dict(color="#ff6b6b", size=16),
238
+ xref="paper",
239
+ yref="paper",
240
+ x=0.5,
241
+ y=0.5,
242
+ )
243
+ fig.update_layout(
244
+ template="plotly_dark",
245
+ title=title,
246
+ xaxis=dict(visible=False),
247
+ yaxis=dict(visible=False),
248
+ height=360,
249
+ paper_bgcolor="rgba(0,0,0,0)",
250
+ plot_bgcolor="rgba(0,0,0,0)",
251
+ )
252
+ return fig
{core β†’ presentation/components}/multi_charts.py RENAMED
File without changes
{core β†’ presentation/components}/visual_comparison.py RENAMED
@@ -7,26 +7,46 @@ 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 +79,20 @@ 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 +124,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: # noqa: BLE001
46
+ return _error_figure(
47
+ "Price Comparison",
48
+ "Failed to load data. Please try again later.",
49
+ )
50
 
51
  fig = go.Figure()
52
  fig.add_trace(go.Scatter(
 
79
  """Build comparative volatility chart for selected pair."""
80
  coin_a, coin_b = pair
81
 
82
+ try:
83
+ df_a = get_coin_history(coin_a, days)
84
+ df_b = get_coin_history(coin_b, days)
85
+ except CacheUnavailableError as e:
86
+ wait = int(e.retry_in) + 1
87
+ return _error_figure(
88
+ "Volatility Comparison",
89
+ f"API cooling down. Retry in ~{wait} seconds.",
90
+ )
91
+ except Exception: # noqa: BLE001
92
+ return _error_figure(
93
+ "Volatility Comparison",
94
+ "Failed to load data. Please try again later.",
95
+ )
96
 
97
  df_a["returns"] = df_a["price"].pct_change() * 100
98
  df_b["returns"] = df_b["price"].pct_change() * 100
 
124
  )
125
 
126
  return fig
127
+
128
+
129
+ def preload_pairs(pairs: list[tuple[str, str]], days: int = 180) -> None:
130
+ """Warm up the cache for all coins involved in the provided pairs."""
131
+
132
+ coins = {coin for pair in pairs for coin in pair}
133
+ for coin in coins:
134
+ try:
135
+ get_coin_history(coin, days)
136
+ except CacheUnavailableError:
137
+ continue
138
+ except Exception:
139
+ continue
140
+
141
+
142
+ def _error_figure(title: str, message: str):
143
+ fig = go.Figure()
144
+ fig.add_annotation(
145
+ text=message,
146
+ showarrow=False,
147
+ font=dict(color="#ff6b6b", size=16),
148
+ xref="paper",
149
+ yref="paper",
150
+ x=0.5,
151
+ y=0.5,
152
+ )
153
+ fig.update_layout(
154
+ template="plotly_dark",
155
+ title=title,
156
+ xaxis=dict(visible=False),
157
+ yaxis=dict(visible=False),
158
+ height=420,
159
+ )
160
+ 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
@@ -16,3 +16,13 @@ h2, h3, .gr-markdown { color:#f0f6fc !important; font-weight:600 !important; }
16
  .gr-dataframe table { width:100% !important; color:#c9d1d9 !important; background:#161b22 !important; }
17
  .gr-dataframe th { background:#21262d !important; color:#f0f6fc !important; border-bottom:1px solid #30363d !important; }
18
  .gr-dataframe td { border-top:1px solid #30363d !important; padding:8px !important; }
 
 
 
 
 
 
 
 
 
 
 
16
  .gr-dataframe table { width:100% !important; color:#c9d1d9 !important; background:#161b22 !important; }
17
  .gr-dataframe th { background:#21262d !important; color:#f0f6fc !important; border-bottom:1px solid #30363d !important; }
18
  .gr-dataframe td { border-top:1px solid #30363d !important; padding:8px !important; }
19
+
20
+ #comparison_table table { table-layout:fixed; }
21
+ #comparison_table table th:nth-child(1),
22
+ #comparison_table table td:nth-child(1) { width:40% !important; }
23
+ #comparison_table table th:nth-child(2),
24
+ #comparison_table table td:nth-child(2),
25
+ #comparison_table table th:nth-child(3),
26
+ #comparison_table table td:nth-child(3),
27
+ #comparison_table table th:nth-child(4),
28
+ #comparison_table table td:nth-child(4) { width:20% !important; }
{core/styles β†’ presentation/styles/themes}/crypto_dashboard.css RENAMED
@@ -26,10 +26,17 @@
26
  align-items: center;
27
  justify-content: flex-start;
28
  min-height: 24px;
29
- border-bottom: 1px solid #30363d;
30
  transition: all 0.2s ease-in-out;
31
  }
32
 
 
 
 
 
 
 
 
 
33
  /* KPI items */
34
  .kpi-item {
35
  margin-right: 14px;
 
26
  align-items: center;
27
  justify-content: flex-start;
28
  min-height: 24px;
 
29
  transition: all 0.2s ease-in-out;
30
  }
31
 
32
+ #kpi_row .gr-html,
33
+ #kpi_line,
34
+ #kpi_line .kpi-line {
35
+ background: transparent !important;
36
+ box-shadow: none !important;
37
+ border: none !important;
38
+ }
39
+
40
  /* KPI items */
41
  .kpi-item {
42
  margin-right: 14px;
{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.