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

Add crypto news digest tab and refine bubble scaling

Browse files
Files changed (35) hide show
  1. README.md +11 -0
  2. app.py +149 -41
  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 +5 -0
  9. core/__init__.py +13 -2
  10. core/comparison_table.py +0 -70
  11. core/news_digest.py +114 -0
  12. core/visual_comparison.py +0 -94
  13. domain/__init__.py +3 -0
  14. infrastructure/__init__.py +20 -0
  15. infrastructure/cache.py +167 -0
  16. {services β†’ infrastructure}/llm_client.py +0 -0
  17. infrastructure/market_data/__init__.py +5 -0
  18. core/data_binance.py β†’ infrastructure/market_data/binance.py +0 -0
  19. core/data_coinlore.py β†’ infrastructure/market_data/coinlore.py +0 -0
  20. core/data_yfinance.py β†’ infrastructure/market_data/yfinance.py +0 -0
  21. {services β†’ infrastructure}/output_api.py +69 -21
  22. presentation/__init__.py +5 -0
  23. presentation/components/__init__.py +17 -0
  24. presentation/components/comparison_table.py +97 -0
  25. {core β†’ presentation/components}/crypto_dashboard.py +167 -15
  26. {core β†’ presentation/components}/multi_charts.py +0 -0
  27. presentation/components/visual_comparison.py +217 -0
  28. {core β†’ presentation/components}/visualization.py +0 -0
  29. presentation/styles/__init__.py +5 -0
  30. presentation/styles/themes/__init__.py +3 -0
  31. {core/styles β†’ presentation/styles/themes}/base.css +10 -0
  32. {core/styles β†’ presentation/styles/themes}/crypto_dashboard.css +8 -1
  33. {core/styles β†’ presentation/styles/themes}/multi_charts.css +0 -0
  34. {core β†’ presentation/styles}/ui_style.css +0 -0
  35. 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,20 @@
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 +22,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,65 +48,164 @@ 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")
72
 
73
  available_pairs = [
74
  ("bitcoin", "ethereum"),
75
- ("bitcoin", "solana"),
76
  ("ethereum", "bnb"),
77
- ("solana", "dogecoin"),
78
- ("bitcoin", "toncoin"),
 
79
  ]
80
 
81
- pair_selector = gr.Dropdown(
82
- label="Select Pair for Comparison",
83
- choices=[f"{a.capitalize()} vs {b.capitalize()}" for a, b in available_pairs],
84
- value="Bitcoin vs Ethereum",
85
- interactive=True,
86
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
  price_plot = gr.Plot(label="Price Comparison")
89
  vol_plot = gr.Plot(label="Volatility Comparison")
90
 
91
- def update_visuals(selected_pair: str):
92
- for a, b in available_pairs:
93
- if selected_pair.lower() == f"{a} vs {b}":
94
- return build_price_chart((a, b)), build_volatility_chart((a, b))
95
- return build_price_chart(("bitcoin", "ethereum")), build_volatility_chart(("bitcoin", "ethereum"))
 
96
 
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])
103
 
 
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 core.news_digest import fetch_crypto_news, summarize_news
9
+ from presentation.components.crypto_dashboard import (
10
+ build_crypto_dashboard,
11
+ ) # Plotly dashboard + KPI-line
12
+ from presentation.components.comparison_table import show_comparison_table
13
+ from presentation.components.visual_comparison import (
14
+ build_comparison_chart,
15
+ build_volatility_chart,
16
+ preload_pairs,
17
+ ) # Interactive pair comparison
18
 
19
  # === CSS loader ===
20
  def load_css(path: str) -> str:
 
22
  return f.read()
23
 
24
  # === Styles ===
25
+ base_css = load_css("presentation/styles/themes/base.css")
26
+ crypto_css = load_css("presentation/styles/themes/crypto_dashboard.css")
27
 
28
  # === Model setup ===
29
  MODEL_NAME = "meta-llama/Meta-Llama-3.1-8B-Instruct"
 
48
  value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb",
49
  )
50
  analyze_btn = gr.Button("Run Analysis", variant="primary")
51
+ analyze_out = gr.Textbox(
52
+ label="Analysis Result",
53
+ lines=15,
54
+ elem_id="analysis_output",
55
+ interactive=False,
56
+ )
57
+ analyze_btn.click(
58
+ fn=analyzer.run,
59
+ inputs=portfolio_input,
60
+ outputs=analyze_out,
61
+ show_progress="minimal",
62
+ )
63
 
64
  # --- Comparison Table ---
65
  with gr.TabItem("Comparison Table"):
66
+ with gr.Row():
67
+ pid_a = gr.Textbox(
68
+ label="Portfolio A",
69
+ value="3852a354-e66e-4bc5-97e9-55124e31e687",
70
+ scale=1,
71
+ )
72
+ pid_b = gr.Textbox(
73
+ label="Portfolio B",
74
+ value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb",
75
+ scale=1,
76
+ )
77
  compare_btn = gr.Button("Load Comparison", variant="primary")
78
+ comp_table = gr.Dataframe(
79
+ label="Comparative Metrics",
80
+ wrap=True,
81
+ elem_id="comparison_table",
82
+ )
83
+ comp_comment = gr.Textbox(
84
+ label="AI Commentary",
85
+ lines=14,
86
+ elem_id="llm_comment_box",
87
+ interactive=False,
88
+ )
89
+ compare_btn.click(
90
+ fn=show_comparison_table,
91
+ inputs=[pid_a, pid_b],
92
+ outputs=[comp_table, comp_comment],
93
+ show_progress="minimal",
94
+ )
95
 
96
  # --- Metrics Table ---
97
  with gr.TabItem("Metrics Table"):
98
+ metrics_in = gr.Textbox(
99
+ label="Portfolio ID",
100
+ value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb",
101
+ )
102
  metrics_btn = gr.Button("Load Metrics", variant="primary")
103
  metrics_out = gr.Dataframe(label="Portfolio Metrics", wrap=True)
104
  metrics_btn.click(fn=show_metrics_table, inputs=metrics_in, outputs=metrics_out)
105
 
106
+ # --- Assistant ---
107
+ with gr.TabItem("Assistant"):
108
+ chat_in = gr.Textbox(label="Ask about investments or analysis")
109
+ chat_btn = gr.Button("Send Question", variant="primary")
110
+ chat_out = gr.Textbox(label="AI Response", lines=8, interactive=False)
111
+ chat_btn.click(
112
+ fn=chatbot.run,
113
+ inputs=chat_in,
114
+ outputs=chat_out,
115
+ show_progress="minimal",
116
+ )
117
+
118
+ # --- Crypto News Digest ---
119
+ with gr.TabItem("Crypto News Digest"):
120
+ gr.Markdown("### πŸ—žοΈ Latest Crypto News (via NewsData.io)")
121
+ headlines_box = gr.Markdown("Fetching latest headlines...", elem_id="news_headlines")
122
+ ai_summary_box = gr.Textbox(
123
+ label="AI Market Summary",
124
+ lines=10,
125
+ interactive=False,
126
+ )
127
+ refresh_btn = gr.Button("πŸ”„ Refresh News", variant="primary")
128
+
129
+ def run_news():
130
+ headlines, ok = fetch_crypto_news()
131
+ if not ok:
132
+ return headlines, "⚠️ Summary will appear once fresh headlines are available."
133
+ summary = summarize_news(headlines)
134
+ return headlines, summary
135
+
136
+ refresh_btn.click(
137
+ fn=run_news,
138
+ inputs=None,
139
+ outputs=[headlines_box, ai_summary_box],
140
+ show_progress="minimal",
141
+ )
142
+ demo.load(
143
+ fn=run_news,
144
+ inputs=None,
145
+ outputs=[headlines_box, ai_summary_box],
146
+ )
147
+
148
  # --- Visual Comparison (Interactive Plotly Edition) ---
149
  with gr.TabItem("Visual Comparison"):
150
  gr.Markdown("### πŸ“Š Market Pair Comparison β€” Interactive Plotly Edition")
151
 
152
  available_pairs = [
153
  ("bitcoin", "ethereum"),
 
154
  ("ethereum", "bnb"),
155
+ ("solana", "avalanche-2"),
156
+ ("litecoin", "bitcoin-cash"),
157
+ ("dogecoin", "shiba-inu"),
158
  ]
159
 
160
+ def _format_pair(pair: tuple[str, str]) -> str:
161
+ def _label(asset: str) -> str:
162
+ return asset.replace("-", " ").title()
163
+
164
+ a, b = pair
165
+ return f"{_label(a)} vs {_label(b)}"
166
+
167
+ pair_map = {_format_pair(pair): pair for pair in available_pairs}
168
+ default_label = _format_pair(available_pairs[0])
169
+
170
+ with gr.Row():
171
+ pair_selector = gr.Dropdown(
172
+ label="Select Pair for Comparison",
173
+ choices=list(pair_map.keys()),
174
+ value=default_label,
175
+ interactive=True,
176
+ scale=3,
177
+ )
178
+ normalize_toggle = gr.Checkbox(
179
+ label="Normalized Mode (%)",
180
+ value=False,
181
+ interactive=True,
182
+ scale=1,
183
+ )
184
 
185
  price_plot = gr.Plot(label="Price Comparison")
186
  vol_plot = gr.Plot(label="Volatility Comparison")
187
 
188
+ def update_visuals(selected_pair: str, normalized: bool):
189
+ pair = pair_map.get(selected_pair, available_pairs[0])
190
+ return (
191
+ build_comparison_chart(pair, normalized=normalized),
192
+ build_volatility_chart(pair),
193
+ )
194
 
195
+ pair_selector.change(
196
+ fn=update_visuals,
197
+ inputs=[pair_selector, normalize_toggle],
198
+ outputs=[price_plot, vol_plot],
199
+ )
200
+ normalize_toggle.change(
201
+ fn=update_visuals,
202
+ inputs=[pair_selector, normalize_toggle],
203
+ outputs=[price_plot, vol_plot],
204
+ )
205
 
206
  def init_visuals():
207
+ preload_pairs(available_pairs)
208
+ return update_visuals(default_label, False)
209
 
210
  demo.load(fn=init_visuals, inputs=None, outputs=[price_plot, vol_plot])
211
 
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
@@ -14,7 +14,12 @@ FEATHERLESS_MODEL = "meta-llama/Meta-Llama-3.1-8B-Instruct"
14
 
15
  # === External API Configuration ===
16
  EXTERNAL_API_URL = os.getenv("EXTERNAL_API_URL")
 
17
 
18
  # === Request / Connection Settings ===
19
  REQUEST_TIMEOUT = 15
20
  DEBUG = os.getenv("DEBUG", "false").lower() == "true"
 
 
 
 
 
14
 
15
  # === External API Configuration ===
16
  EXTERNAL_API_URL = os.getenv("EXTERNAL_API_URL")
17
+ NEWSDATA_API_KEY = os.getenv("NEWSDATA_API_KEY")
18
 
19
  # === Request / Connection Settings ===
20
  REQUEST_TIMEOUT = 15
21
  DEBUG = os.getenv("DEBUG", "false").lower() == "true"
22
+
23
+ # === Caching Settings ===
24
+ CACHE_TTL_SECONDS = int(os.getenv("CACHE_TTL_SECONDS", "600")) # 10 minutes
25
+ 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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
core/news_digest.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Helpers for fetching crypto news and summarizing sentiment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from typing import List, Tuple
7
+
8
+ import requests
9
+
10
+ from config import (
11
+ CACHE_RETRY_SECONDS,
12
+ CACHE_TTL_SECONDS,
13
+ NEWSDATA_API_KEY,
14
+ REQUEST_TIMEOUT,
15
+ )
16
+ from infrastructure.cache import CacheUnavailableError, TTLCache
17
+ from infrastructure.llm_client import llm_service
18
+
19
+
20
+ _news_cache: TTLCache[List[dict]] = TTLCache(CACHE_TTL_SECONDS, CACHE_RETRY_SECONDS)
21
+
22
+
23
+ def _load_news_payload() -> List[dict]:
24
+ if not NEWSDATA_API_KEY:
25
+ raise CacheUnavailableError("News API key is missing.", CACHE_RETRY_SECONDS)
26
+
27
+ url = "https://newsdata.io/api/1/news"
28
+ params = {
29
+ "apikey": NEWSDATA_API_KEY,
30
+ "q": "crypto",
31
+ "language": "en",
32
+ "category": "business",
33
+ }
34
+
35
+ try:
36
+ response = requests.get(url, params=params, timeout=REQUEST_TIMEOUT)
37
+ response.raise_for_status()
38
+ except requests.RequestException as exc: # noqa: PERF203 - surface API issues
39
+ raise CacheUnavailableError("News service request failed.", CACHE_RETRY_SECONDS) from exc
40
+
41
+ payload = response.json()
42
+ results = payload.get("results")
43
+ if not isinstance(results, list):
44
+ raise CacheUnavailableError("News service returned unexpected format.", CACHE_RETRY_SECONDS)
45
+
46
+ return results
47
+
48
+
49
+ def _format_timestamp(value: str | None) -> str:
50
+ if not value:
51
+ return "Unknown time"
52
+ try:
53
+ parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
54
+ return parsed.strftime("%Y-%m-%d %H:%M UTC")
55
+ except ValueError:
56
+ return value
57
+
58
+
59
+ def fetch_crypto_news() -> Tuple[str, bool]:
60
+ """Return Markdown-formatted headlines and success flag."""
61
+
62
+ try:
63
+ items = _news_cache.get("crypto_news", _load_news_payload)
64
+ except CacheUnavailableError as exc:
65
+ wait = int(exc.retry_in) + 1
66
+ return f"⚠️ News service cooling down. Retry in ~{wait} seconds.", False
67
+ except Exception:
68
+ return "❌ Failed to load crypto headlines. Please try again later.", False
69
+
70
+ lines = []
71
+ for idx, item in enumerate(items[:5]):
72
+ title = (item.get("title") or "Untitled headline").strip()
73
+ link = (item.get("link") or "").strip() or "#"
74
+ source = (item.get("source_id") or "Unknown source").strip()
75
+ timestamp = _format_timestamp(item.get("pubDate"))
76
+ lines.append(f"{idx + 1}. [{title}]({link}) β€” *{source}* ({timestamp})")
77
+
78
+ if not lines:
79
+ return "⚠️ No recent crypto headlines were returned.", False
80
+
81
+ return "\n".join(lines), True
82
+
83
+
84
+ def summarize_news(headlines_text: str) -> str:
85
+ if not headlines_text.strip():
86
+ return "⚠️ No headlines available for summarization."
87
+
88
+ messages = [
89
+ {
90
+ "role": "system",
91
+ "content": (
92
+ "You are a crypto market strategist. Write a concise 4-5 sentence digest of the "
93
+ "current market tone using the provided headlines. Highlight catalysts, risks, "
94
+ "and cross-asset themes. Finish with a separate line in the format "
95
+ "'Market Tone: <emoji> <Bullish/Bearish/Neutral> β€” <one-sentence rationale>'."
96
+ ),
97
+ },
98
+ {
99
+ "role": "user",
100
+ "content": (
101
+ "Analyze these crypto news headlines and summarize market sentiment:\n"
102
+ f"{headlines_text}"
103
+ ),
104
+ },
105
+ ]
106
+
107
+ try:
108
+ chunks = []
109
+ for delta in llm_service.stream_chat(messages=messages):
110
+ chunks.append(delta)
111
+ summary = "".join(chunks).strip()
112
+ return summary or "⚠️ Summary is unavailable right now."
113
+ except Exception:
114
+ return "❌ Unable to summarize the news at the moment. Please try again later."
core/visual_comparison.py DELETED
@@ -1,94 +0,0 @@
1
- """
2
- Module: visual_comparison.py
3
- Purpose: Interactive crypto pair comparison (Plotly + CoinGecko)
4
- """
5
-
6
- 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(
33
- x=df_a["timestamp"],
34
- y=df_a["price"],
35
- name=f"{coin_a.capitalize()} / USD",
36
- line=dict(width=2),
37
- ))
38
- fig.add_trace(go.Scatter(
39
- x=df_b["timestamp"],
40
- y=df_b["price"],
41
- name=f"{coin_b.capitalize()} / USD",
42
- line=dict(width=2),
43
- ))
44
-
45
- fig.update_layout(
46
- template="plotly_dark",
47
- height=480,
48
- margin=dict(l=40, r=20, t=30, b=40),
49
- xaxis_title="Date",
50
- yaxis_title="Price (USD)",
51
- legend_title="Asset",
52
- hovermode="x unified",
53
- )
54
-
55
- return fig
56
-
57
-
58
- 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
67
-
68
- fig = go.Figure()
69
- fig.add_trace(go.Scatter(
70
- x=df_a["timestamp"],
71
- y=df_a["returns"],
72
- name=f"{coin_a.upper()} Daily Change (%)",
73
- mode="lines",
74
- line=dict(width=1.6),
75
- ))
76
- fig.add_trace(go.Scatter(
77
- x=df_b["timestamp"],
78
- y=df_b["returns"],
79
- name=f"{coin_b.upper()} Daily Change (%)",
80
- mode="lines",
81
- line=dict(width=1.6),
82
- ))
83
-
84
- fig.update_layout(
85
- template="plotly_dark",
86
- height=400,
87
- margin=dict(l=40, r=20, t=30, b=40),
88
- xaxis_title="Date",
89
- yaxis_title="Daily Change (%)",
90
- legend_title="Volatility",
91
- hovermode="x unified",
92
- )
93
-
94
- return fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (
6
+ build_comparison_chart,
7
+ build_price_chart,
8
+ build_volatility_chart,
9
+ )
10
+
11
+ __all__ = [
12
+ "show_comparison_table",
13
+ "build_crypto_dashboard",
14
+ "build_comparison_chart",
15
+ "build_price_chart",
16
+ "build_volatility_chart",
17
+ ]
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,58 @@ 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 +79,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(
@@ -81,11 +140,36 @@ def build_crypto_dashboard(top_n=50):
81
  )
82
 
83
  # === Scatter (Market Cap vs Volume) ===
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  fig_bubble = px.scatter(
85
- df.head(60),
86
  x="market_cap_usd",
87
  y="volume24",
88
- size="price_usd",
89
  color="percent_change_7d",
90
  hover_name="symbol",
91
  log_x=True,
@@ -109,18 +193,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 numpy as np
10
  import pandas as pd
11
  import plotly.express as px
12
+ import plotly.graph_objects as go
13
+
14
+ from config import CACHE_RETRY_SECONDS, CACHE_TTL_SECONDS
15
+ from infrastructure.cache import CacheUnavailableError, TTLCache
16
+ from infrastructure.llm_client import llm_service
17
+
18
+
19
+ _coinlore_cache = TTLCache(CACHE_TTL_SECONDS, CACHE_RETRY_SECONDS)
20
 
21
 
22
+ def _load_coinlore() -> pd.DataFrame:
23
  url = "https://api.coinlore.net/api/tickers/"
24
+ try:
25
+ response = requests.get(url, timeout=20)
26
+ response.raise_for_status()
27
+ payload = response.json()
28
+ data = payload.get("data")
29
+ if not isinstance(data, list):
30
+ raise ValueError("Unexpected Coinlore payload structure")
31
+ except requests.RequestException as exc: # noqa: PERF203 - propagate meaningful message
32
+ raise CacheUnavailableError(
33
+ "Coinlore API request failed.",
34
+ CACHE_RETRY_SECONDS,
35
+ ) from exc
36
+ except ValueError as exc:
37
+ raise CacheUnavailableError(
38
+ "Coinlore API returned unexpected response.",
39
+ CACHE_RETRY_SECONDS,
40
+ ) from exc
41
+
42
  df = pd.DataFrame(data)
43
+ for col in [
44
+ "price_usd",
45
+ "market_cap_usd",
46
+ "volume24",
47
+ "percent_change_1h",
48
+ "percent_change_24h",
49
+ "percent_change_7d",
50
+ ]:
51
  df[col] = pd.to_numeric(df[col], errors="coerce")
52
+ return df
53
+
54
+
55
+ def fetch_coinlore_data(limit: int = 100) -> pd.DataFrame:
56
+ """Return cached Coinlore data limited to the requested number of rows."""
57
+
58
+ base = _coinlore_cache.get("coinlore", _load_coinlore)
59
+ return base.head(limit).copy()
60
 
61
 
62
  def _kpi_line(df) -> str:
 
79
 
80
 
81
  def build_crypto_dashboard(top_n=50):
82
+ try:
83
+ df = fetch_coinlore_data(top_n)
84
+ except CacheUnavailableError as e:
85
+ wait = int(e.retry_in) + 1
86
+ message = f"⚠️ Coinlore API cooling down. Retry in ~{wait} seconds."
87
+ return (
88
+ _error_figure("Market Composition", message),
89
+ _error_figure("Top Movers", message),
90
+ _error_figure("Market Cap vs Volume", message),
91
+ message,
92
+ message,
93
+ )
94
+ except Exception: # noqa: BLE001 - surface unexpected failures
95
+ message = "❌ Failed to load market data. Please try again later."
96
+ return (
97
+ _error_figure("Market Composition", message),
98
+ _error_figure("Top Movers", message),
99
+ _error_figure("Market Cap vs Volume", message),
100
+ message,
101
+ message,
102
+ )
103
 
104
  # === Treemap ===
105
  fig_treemap = px.treemap(
 
140
  )
141
 
142
  # === Scatter (Market Cap vs Volume) ===
143
+ bubble_df = df.head(60).copy()
144
+ if not bubble_df.empty:
145
+ cap = bubble_df["market_cap_usd"].fillna(0).clip(lower=1.0)
146
+ rank = cap.rank(pct=True)
147
+
148
+ sqrt_cap = np.sqrt(cap)
149
+ sqrt_min, sqrt_max = float(sqrt_cap.min()), float(sqrt_cap.max())
150
+ if sqrt_max - sqrt_min > 0:
151
+ sqrt_norm = (sqrt_cap - sqrt_min) / (sqrt_max - sqrt_min)
152
+ else:
153
+ sqrt_norm = pd.Series(0.0, index=bubble_df.index)
154
+
155
+ log_cap = np.log1p(cap)
156
+ log_min, log_max = float(log_cap.min()), float(log_cap.max())
157
+ if log_max - log_min > 0:
158
+ log_norm = (log_cap - log_min) / (log_max - log_min)
159
+ else:
160
+ log_norm = pd.Series(0.0, index=bubble_df.index)
161
+
162
+ hybrid = 0.55 * rank + 0.30 * sqrt_norm + 0.15 * log_norm
163
+ hybrid = np.power(hybrid, 0.85)
164
+ bubble_df["bubble_size"] = 10 + (56 - 10) * hybrid
165
+ else:
166
+ bubble_df["bubble_size"] = 10
167
+
168
  fig_bubble = px.scatter(
169
+ bubble_df,
170
  x="market_cap_usd",
171
  y="volume24",
172
+ size="bubble_size",
173
  color="percent_change_7d",
174
  hover_name="symbol",
175
  log_x=True,
 
193
 
194
 
195
  def _ai_summary(df):
196
+ timestamp = pd.Timestamp.utcnow().strftime("%Y-%m-%d %H:%M UTC")
197
  leaders = df.sort_values("percent_change_24h", ascending=False).head(3)["symbol"].tolist()
198
  laggards = df.sort_values("percent_change_24h").head(3)["symbol"].tolist()
199
+
200
+ total_cap = float(df["market_cap_usd"].sum()) if not df.empty else 0.0
201
+ total_volume = float(df["volume24"].sum()) if not df.empty else 0.0
202
+ btc_cap = float(df.loc[df["symbol"] == "BTC", "market_cap_usd"].sum()) if total_cap else 0.0
203
+ btc_dominance = (btc_cap / total_cap * 100) if total_cap else 0.0
204
+
205
+ snapshot_rows = (
206
+ df.sort_values("market_cap_usd", ascending=False)
207
+ .head(12)
208
+ [["symbol", "price_usd", "percent_change_24h", "percent_change_7d", "volume24"]]
209
+ )
210
+ lines = []
211
+ for row in snapshot_rows.itertuples(index=False):
212
+ lines.append(
213
+ (
214
+ f"{row.symbol}: price ${row.price_usd:,.2f}, "
215
+ f"24h {row.percent_change_24h:+.2f}%, "
216
+ f"7d {row.percent_change_7d:+.2f}%, "
217
+ f"24h volume ${row.volume24:,.0f}"
218
+ )
219
+ )
220
+ snapshot_text = "\n".join(lines)
221
+
222
+ system_prompt = (
223
+ "You are a crypto market strategist receiving a fresh Coinlore snapshot. "
224
+ "Use only the provided metrics to deliver an actionable analysis. "
225
+ "Do not mention training cutoffs or missing live accessβ€”assume the snapshot reflects the current market."
226
+ )
227
+ user_prompt = f"""
228
+ Coinlore snapshot captured at {timestamp}.
229
+ Aggregate totals:
230
+ - Total market cap (tracked set): ${total_cap:,.0f}
231
+ - 24h traded volume: ${total_volume:,.0f}
232
+ - BTC dominance: {btc_dominance:.2f}%
233
+
234
+ Key movers by 24h change:
235
+ {snapshot_text or 'No data available.'}
236
+
237
+ Top gainers (24h): {', '.join(leaders) if leaders else 'n/a'}
238
+ Top laggards (24h): {', '.join(laggards) if laggards else 'n/a'}
239
+
240
+ Provide:
241
+ 1. Market sentiment and breadth.
242
+ 2. Liquidity and volatility observations.
243
+ 3. Short-term outlook and immediate risks, grounded in this snapshot.
244
  """
245
+
246
  text = ""
247
  for delta in llm_service.stream_chat(
248
+ messages=[
249
+ {"role": "system", "content": system_prompt},
250
+ {"role": "user", "content": user_prompt},
251
+ ],
252
  model="meta-llama/Meta-Llama-3.1-8B-Instruct",
253
  ):
254
  text += delta
255
  return text
256
+
257
+
258
+ def _error_figure(title: str, message: str) -> go.Figure:
259
+ fig = go.Figure()
260
+ fig.add_annotation(
261
+ text=message,
262
+ showarrow=False,
263
+ font=dict(color="#ff6b6b", size=16),
264
+ xref="paper",
265
+ yref="paper",
266
+ x=0.5,
267
+ y=0.5,
268
+ )
269
+ fig.update_layout(
270
+ template="plotly_dark",
271
+ title=title,
272
+ xaxis=dict(visible=False),
273
+ yaxis=dict(visible=False),
274
+ height=360,
275
+ paper_bgcolor="rgba(0,0,0,0)",
276
+ plot_bgcolor="rgba(0,0,0,0)",
277
+ )
278
+ return fig
{core β†’ presentation/components}/multi_charts.py RENAMED
File without changes
presentation/components/visual_comparison.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Module: visual_comparison.py
3
+ Purpose: Interactive crypto pair comparison (Plotly + CoinGecko)
4
+ """
5
+
6
+ import requests
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 _asset_label(asset: str) -> str:
19
+ """Format asset identifiers for display."""
20
+
21
+ return asset.replace("-", " ").title()
22
+
23
+
24
+ def get_coin_history(coin_id: str, days: int = 180):
25
+ """Fetch historical market data for given coin from CoinGecko API."""
26
+ def _load():
27
+ url = f"{COINGECKO_API}/coins/{coin_id}/market_chart?vs_currency=usd&days={days}"
28
+ r = requests.get(url, timeout=20)
29
+ r.raise_for_status()
30
+ data = r.json()
31
+ df = pd.DataFrame(data["prices"], columns=["timestamp", "price"])
32
+ df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
33
+ return df
34
+
35
+ return _history_cache.get((coin_id, days), _load)
36
+
37
+
38
+ def build_price_chart(
39
+ pair: tuple[str, str],
40
+ days: int = 180,
41
+ *,
42
+ normalized: bool = False,
43
+ ):
44
+ """Build comparative price chart for selected pair."""
45
+ coin_a, coin_b = pair
46
+
47
+ try:
48
+ df_a = get_coin_history(coin_a, days)
49
+ df_b = get_coin_history(coin_b, days)
50
+ except CacheUnavailableError as e:
51
+ wait = int(e.retry_in) + 1
52
+ return _error_figure(
53
+ "Normalized Growth (Index = 1.0)" if normalized else "Price Comparison",
54
+ f"API cooling down. Retry in ~{wait} seconds.",
55
+ )
56
+ except Exception: # noqa: BLE001
57
+ return _error_figure(
58
+ "Normalized Growth (Index = 1.0)" if normalized else "Price Comparison",
59
+ "Failed to load data. Please try again later.",
60
+ )
61
+
62
+ y_title = "Price (USD)"
63
+ chart_title = "Price Comparison"
64
+ y_a = df_a["price"]
65
+ y_b = df_b["price"]
66
+ hovertemplate = None
67
+
68
+ if normalized:
69
+ def _normalize(series: pd.Series) -> pd.Series:
70
+ first = series.iloc[0]
71
+ if pd.isna(first) or first == 0:
72
+ return pd.Series([0.0] * len(series), index=series.index)
73
+ return ((series / first) - 1) * 100
74
+
75
+ y_a = _normalize(df_a["price"])
76
+ y_b = _normalize(df_b["price"])
77
+ y_title = "Relative Growth (%)"
78
+ chart_title = "Normalized Growth (Index = 1.0)"
79
+ hovertemplate = "%{y:.2f}%<extra>%{fullData.name}</extra>"
80
+
81
+ fig = go.Figure()
82
+ fig.add_trace(
83
+ go.Scatter(
84
+ x=df_a["timestamp"],
85
+ y=y_a,
86
+ name=(
87
+ f"{_asset_label(coin_a)} / USD"
88
+ if not normalized
89
+ else f"{_asset_label(coin_a)} Indexed"
90
+ ),
91
+ line=dict(width=2),
92
+ hovertemplate=hovertemplate,
93
+ )
94
+ )
95
+ fig.add_trace(
96
+ go.Scatter(
97
+ x=df_b["timestamp"],
98
+ y=y_b,
99
+ name=(
100
+ f"{_asset_label(coin_b)} / USD"
101
+ if not normalized
102
+ else f"{_asset_label(coin_b)} Indexed"
103
+ ),
104
+ line=dict(width=2),
105
+ hovertemplate=hovertemplate,
106
+ )
107
+ )
108
+
109
+ fig.update_layout(
110
+ template="plotly_dark",
111
+ height=480,
112
+ margin=dict(l=40, r=20, t=30, b=40),
113
+ xaxis_title="Date",
114
+ yaxis_title=y_title,
115
+ legend_title="Asset" if not normalized else "Asset (Indexed)",
116
+ title=chart_title,
117
+ hovermode="x unified",
118
+ )
119
+
120
+ fig.update_yaxes(ticksuffix="%" if normalized else None)
121
+
122
+ return fig
123
+
124
+
125
+ def build_comparison_chart(
126
+ pair: tuple[str, str],
127
+ days: int = 180,
128
+ normalized: bool = False,
129
+ ):
130
+ """Convenience wrapper for the price/normalized comparison chart."""
131
+
132
+ return build_price_chart(pair, days=days, normalized=normalized)
133
+
134
+
135
+ def build_volatility_chart(pair: tuple[str, str], days: int = 180):
136
+ """Build comparative volatility chart for selected pair."""
137
+ coin_a, coin_b = pair
138
+
139
+ try:
140
+ df_a = get_coin_history(coin_a, days)
141
+ df_b = get_coin_history(coin_b, days)
142
+ except CacheUnavailableError as e:
143
+ wait = int(e.retry_in) + 1
144
+ return _error_figure(
145
+ "Volatility Comparison",
146
+ f"API cooling down. Retry in ~{wait} seconds.",
147
+ )
148
+ except Exception: # noqa: BLE001
149
+ return _error_figure(
150
+ "Volatility Comparison",
151
+ "Failed to load data. Please try again later.",
152
+ )
153
+
154
+ df_a["returns"] = df_a["price"].pct_change() * 100
155
+ df_b["returns"] = df_b["price"].pct_change() * 100
156
+
157
+ fig = go.Figure()
158
+ fig.add_trace(go.Scatter(
159
+ x=df_a["timestamp"],
160
+ y=df_a["returns"],
161
+ name=f"{coin_a.upper()} Daily Change (%)",
162
+ mode="lines",
163
+ line=dict(width=1.6),
164
+ ))
165
+ fig.add_trace(go.Scatter(
166
+ x=df_b["timestamp"],
167
+ y=df_b["returns"],
168
+ name=f"{coin_b.upper()} Daily Change (%)",
169
+ mode="lines",
170
+ line=dict(width=1.6),
171
+ ))
172
+
173
+ fig.update_layout(
174
+ template="plotly_dark",
175
+ height=400,
176
+ margin=dict(l=40, r=20, t=30, b=40),
177
+ xaxis_title="Date",
178
+ yaxis_title="Daily Change (%)",
179
+ legend_title="Volatility",
180
+ hovermode="x unified",
181
+ )
182
+
183
+ return fig
184
+
185
+
186
+ def preload_pairs(pairs: list[tuple[str, str]], days: int = 180) -> None:
187
+ """Warm up the cache for all coins involved in the provided pairs."""
188
+
189
+ coins = {coin for pair in pairs for coin in pair}
190
+ for coin in coins:
191
+ try:
192
+ get_coin_history(coin, days)
193
+ except CacheUnavailableError:
194
+ continue
195
+ except Exception:
196
+ continue
197
+
198
+
199
+ def _error_figure(title: str, message: str):
200
+ fig = go.Figure()
201
+ fig.add_annotation(
202
+ text=message,
203
+ showarrow=False,
204
+ font=dict(color="#ff6b6b", size=16),
205
+ xref="paper",
206
+ yref="paper",
207
+ x=0.5,
208
+ y=0.5,
209
+ )
210
+ fig.update_layout(
211
+ template="plotly_dark",
212
+ title=title,
213
+ xaxis=dict(visible=False),
214
+ yaxis=dict(visible=False),
215
+ height=420,
216
+ )
217
+ 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.