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

Refine analyzer streaming format and prompt

Browse files
Files changed (39) hide show
  1. README.md +11 -0
  2. app.py +147 -46
  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 +49 -24
  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/styles/base.css +0 -18
  13. core/visual_comparison.py +0 -94
  14. domain/__init__.py +3 -0
  15. infrastructure/__init__.py +20 -0
  16. infrastructure/cache.py +167 -0
  17. {services → infrastructure}/llm_client.py +0 -0
  18. infrastructure/market_data/__init__.py +5 -0
  19. core/data_binance.py → infrastructure/market_data/binance.py +0 -0
  20. core/data_coinlore.py → infrastructure/market_data/coinlore.py +0 -0
  21. core/data_yfinance.py → infrastructure/market_data/yfinance.py +0 -0
  22. {services → infrastructure}/output_api.py +69 -21
  23. presentation/__init__.py +5 -0
  24. presentation/components/__init__.py +17 -0
  25. presentation/components/analysis_formatter.py +324 -0
  26. presentation/components/comparison_table.py +97 -0
  27. {core → presentation/components}/crypto_dashboard.py +167 -15
  28. {core → presentation/components}/multi_charts.py +0 -0
  29. presentation/components/visual_comparison.py +217 -0
  30. {core → presentation/components}/visualization.py +0 -0
  31. presentation/styles/__init__.py +5 -0
  32. presentation/styles/themes/__init__.py +3 -0
  33. presentation/styles/themes/base.css +230 -0
  34. {core/styles → presentation/styles/themes}/crypto_dashboard.css +8 -1
  35. {core/styles → presentation/styles/themes}/multi_charts.css +0 -0
  36. {core → presentation/styles}/ui_style.css +0 -0
  37. prompts/reference_templates.py +8 -13
  38. prompts/system_prompts.py +17 -8
  39. 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,14 +21,13 @@ 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"
21
  analyzer = PortfolioAnalyzer(llm_service, MODEL_NAME)
22
  comparer = PortfolioComparer(llm_service, MODEL_NAME)
23
- chatbot = ChatAssistant(llm_service, MODEL_NAME)
24
 
25
  # === Main Interface ===
26
  with gr.Blocks(css=base_css) as demo:
@@ -39,32 +46,97 @@ 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"):
@@ -72,32 +144,61 @@ with gr.Blocks(css=base_css) as demo:
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.metrics_table import show_metrics_table
4
+ from application.portfolio_analyzer import PortfolioAnalyzer
5
+ from application.portfolio_comparer import PortfolioComparer
6
+ from infrastructure.llm_client import llm_service
7
+ from core.news_digest import fetch_crypto_news, summarize_news
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_comparison_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"
29
  analyzer = PortfolioAnalyzer(llm_service, MODEL_NAME)
30
  comparer = PortfolioComparer(llm_service, MODEL_NAME)
 
31
 
32
  # === Main Interface ===
33
  with gr.Blocks(css=base_css) as demo:
 
46
  value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb",
47
  )
48
  analyze_btn = gr.Button("Run Analysis", variant="primary")
49
+ analyze_out = gr.HTML(value="", elem_id="analysis_output")
50
+ analyze_btn.click(
51
+ fn=analyzer.run,
52
+ inputs=portfolio_input,
53
+ outputs=analyze_out,
54
+ show_progress="minimal",
55
+ )
56
 
57
  # --- Comparison Table ---
58
  with gr.TabItem("Comparison Table"):
59
+ with gr.Row():
60
+ pid_a = gr.Textbox(
61
+ label="Portfolio A",
62
+ value="3852a354-e66e-4bc5-97e9-55124e31e687",
63
+ scale=1,
64
+ )
65
+ pid_b = gr.Textbox(
66
+ label="Portfolio B",
67
+ value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb",
68
+ scale=1,
69
+ )
70
  compare_btn = gr.Button("Load Comparison", variant="primary")
71
+ comp_table = gr.Dataframe(
72
+ label="Comparative Metrics",
73
+ wrap=True,
74
+ elem_id="comparison_table",
75
+ )
76
+ comp_comment = gr.Textbox(
77
+ label="AI Commentary",
78
+ lines=14,
79
+ elem_id="llm_comment_box",
80
+ interactive=False,
81
+ )
82
+ compare_btn.click(
83
+ fn=show_comparison_table,
84
+ inputs=[pid_a, pid_b],
85
+ outputs=[comp_table, comp_comment],
86
+ show_progress="minimal",
87
+ )
88
 
89
  # --- Metrics Table ---
90
+ # with gr.TabItem("Metrics Table"):
91
+ # metrics_in = gr.Textbox(
92
+ # label="Portfolio ID",
93
+ # value="b1ef37aa-5b9a-41b4-9394-8823f2de36bb",
94
+ # )
95
+ # metrics_btn = gr.Button("Load Metrics", variant="primary")
96
+ # metrics_out = gr.Dataframe(label="Portfolio Metrics", wrap=True)
97
+ # metrics_btn.click(fn=show_metrics_table, inputs=metrics_in, outputs=metrics_out)
98
+
99
+ # --- Assistant (temporarily disabled; duplicate removed) ---
100
+ # with gr.TabItem("Assistant"):
101
+ # chat_in = gr.Textbox(label="Ask about investments or analysis")
102
+ # chat_btn = gr.Button("Send Question", variant="primary")
103
+ # chat_out = gr.Textbox(label="AI Response", lines=8, interactive=False)
104
+ # chat_btn.click(
105
+ # fn=chatbot.run,
106
+ # inputs=chat_in,
107
+ # outputs=chat_out,
108
+ # show_progress="minimal",
109
+ # )
110
+
111
+ # --- Crypto News Digest ---
112
+ with gr.TabItem("Crypto News Digest"):
113
+ gr.Markdown("### 🗞️ Latest Crypto News (via NewsData.io)")
114
+ headlines_box = gr.Markdown("Fetching latest headlines...", elem_id="news_headlines")
115
+ ai_summary_box = gr.Textbox(
116
+ label="AI Market Summary",
117
+ lines=10,
118
+ interactive=False,
119
+ )
120
+ refresh_btn = gr.Button("🔄 Refresh News", variant="primary")
121
+
122
+ def run_news():
123
+ headlines, ok = fetch_crypto_news()
124
+ if not ok:
125
+ return headlines, "⚠️ Summary will appear once fresh headlines are available."
126
+ summary = summarize_news(headlines)
127
+ return headlines, summary
128
+
129
+ refresh_btn.click(
130
+ fn=run_news,
131
+ inputs=None,
132
+ outputs=[headlines_box, ai_summary_box],
133
+ show_progress="minimal",
134
+ )
135
+ demo.load(
136
+ fn=run_news,
137
+ inputs=None,
138
+ outputs=[headlines_box, ai_summary_box],
139
+ )
140
 
141
  # --- Visual Comparison (Interactive Plotly Edition) ---
142
  with gr.TabItem("Visual Comparison"):
 
144
 
145
  available_pairs = [
146
  ("bitcoin", "ethereum"),
 
147
  ("ethereum", "bnb"),
148
+ ("solana", "avalanche-2"),
149
+ ("litecoin", "bitcoin-cash"),
150
+ ("dogecoin", "shiba-inu"),
151
  ]
152
 
153
+ def _format_pair(pair: tuple[str, str]) -> str:
154
+ def _label(asset: str) -> str:
155
+ return asset.replace("-", " ").title()
156
+
157
+ a, b = pair
158
+ return f"{_label(a)} vs {_label(b)}"
159
+
160
+ pair_map = {_format_pair(pair): pair for pair in available_pairs}
161
+ default_label = _format_pair(available_pairs[0])
162
+
163
+ with gr.Row():
164
+ pair_selector = gr.Dropdown(
165
+ label="Select Pair for Comparison",
166
+ choices=list(pair_map.keys()),
167
+ value=default_label,
168
+ interactive=True,
169
+ scale=3,
170
+ )
171
+ normalize_toggle = gr.Checkbox(
172
+ label="Normalized Mode (%)",
173
+ value=False,
174
+ interactive=True,
175
+ scale=1,
176
+ )
177
 
178
  price_plot = gr.Plot(label="Price Comparison")
179
  vol_plot = gr.Plot(label="Volatility Comparison")
180
 
181
+ def update_visuals(selected_pair: str, normalized: bool):
182
+ pair = pair_map.get(selected_pair, available_pairs[0])
183
+ return (
184
+ build_comparison_chart(pair, normalized=normalized),
185
+ build_volatility_chart(pair),
186
+ )
187
 
188
+ pair_selector.change(
189
+ fn=update_visuals,
190
+ inputs=[pair_selector, normalize_toggle],
191
+ outputs=[price_plot, vol_plot],
192
+ )
193
+ normalize_toggle.change(
194
+ fn=update_visuals,
195
+ inputs=[pair_selector, normalize_toggle],
196
+ outputs=[price_plot, vol_plot],
197
+ )
198
 
199
  def init_visuals():
200
+ preload_pairs(available_pairs)
201
+ return update_visuals(default_label, False)
202
 
203
  demo.load(fn=init_visuals, inputs=None, outputs=[price_plot, vol_plot])
204
 
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
@@ -1,15 +1,21 @@
1
  """
2
  🇬🇧 Module: analyzer.py()
3
- Purpose: Handles single-portfolio analysis using LLM. Fetches metrics, builds prompt, streams reasoning.
4
 
5
  🇷🇺 Модуль: analyzer.py
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
 
@@ -21,37 +27,56 @@ class PortfolioAnalyzer:
21
  self.llm = llm
22
  self.model_name = model_name
23
 
24
- def run(self, text: str) -> Generator[str, None, None]:
25
- """Stream analysis result step by step."""
26
  portfolio_id = extract_portfolio_id(text)
27
  if not portfolio_id:
28
- yield "❗ Please enter a portfolio ID."
29
  return
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:
39
- yield "❗ Metrics can't be collected."
40
  return
41
 
42
  metrics_text = ", ".join(f"{k}: {v}" for k, v in metrics.items())
43
  prompt = f"{REFERENCE_PROMPT}\n\nИспользуй эти данные для анализа:\n{metrics_text}"
44
 
45
- try:
46
- messages = [
47
- {"role": "system", "content": ANALYSIS_SYSTEM_PROMPT},
48
- {"role": "user", "content": prompt},
49
- ]
50
 
51
- partial = ""
 
 
52
  for delta in self.llm.stream_chat(messages=messages, model=self.model_name):
53
- partial += delta
54
- yield partial
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
- except Exception as e:
57
- yield f"❌ LLM's error: {e}"
 
1
  """
2
  🇬🇧 Module: analyzer.py()
3
+ Purpose: Handles single-portfolio analysis using LLM. Fetches metrics, builds prompt, returns formatted HTML.
4
 
5
  🇷🇺 Модуль: analyzer.py
6
+ Назначение: анализ одного инвестиционного портфеля с использованием LLM. Получает метрики, формирует промпт, возвращает отформатированный HTML-отчёт.
7
  """
8
 
9
+
10
+ from typing import Iterable
11
+
12
+ from infrastructure.cache import CacheUnavailableError
13
+ from infrastructure.output_api import extract_portfolio_id, fetch_metrics_cached
14
+ from infrastructure.llm_client import llm_service
15
+ from presentation.components.analysis_formatter import (
16
+ render_analysis_html,
17
+ render_status_html,
18
+ )
19
  from prompts.system_prompts import ANALYSIS_SYSTEM_PROMPT
20
  from prompts.reference_templates import REFERENCE_PROMPT
21
 
 
27
  self.llm = llm
28
  self.model_name = model_name
29
 
30
+ def run(self, text: str) -> Iterable[str]:
31
+ """Stream formatted HTML analysis while keeping layout consistent."""
32
  portfolio_id = extract_portfolio_id(text)
33
  if not portfolio_id:
34
+ yield render_status_html("❗ Please enter a portfolio ID.")
35
  return
36
 
 
37
  try:
38
+ metrics = fetch_metrics_cached(portfolio_id)
39
+ except CacheUnavailableError as e:
40
+ wait = int(e.retry_in) + 1
41
+ yield render_status_html(
42
+ f"⚠️ API temporarily unavailable. Please retry in ~{wait} seconds."
43
+ )
44
+ return
45
+ except Exception:
46
+ yield render_status_html("❌ Failed to collect metrics. Please try again later.")
47
  return
48
 
49
  if not metrics:
50
+ yield render_status_html("❗ Metrics can't be collected.")
51
  return
52
 
53
  metrics_text = ", ".join(f"{k}: {v}" for k, v in metrics.items())
54
  prompt = f"{REFERENCE_PROMPT}\n\nИспользуй эти данные для анализа:\n{metrics_text}"
55
 
56
+ messages = [
57
+ {"role": "system", "content": ANALYSIS_SYSTEM_PROMPT},
58
+ {"role": "user", "content": prompt},
59
+ ]
 
60
 
61
+ buffer: list[str] = []
62
+ try:
63
+ yield render_status_html("🔄 Generating analysis…")
64
  for delta in self.llm.stream_chat(messages=messages, model=self.model_name):
65
+ if not delta:
66
+ continue
67
+ buffer.append(delta)
68
+ partial = "".join(buffer).strip()
69
+ if not partial:
70
+ continue
71
+ yield render_analysis_html(partial, show_caret=True)
72
+ except Exception:
73
+ yield render_status_html("❌ LLM is unavailable right now. Please try again later.")
74
+ return
75
+
76
+ final_text = "".join(buffer).strip()
77
+ if not final_text:
78
+ yield render_status_html("❗ The analysis did not return any content.")
79
+ return
80
 
81
+ # Ensure the last render is fully trimmed and styled
82
+ yield render_analysis_html(final_text)
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/styles/base.css DELETED
@@ -1,18 +0,0 @@
1
- /* === Global Layout === */
2
- .gradio-container { font-family: 'Inter', sans-serif; background:#0d1117 !important; }
3
- [data-testid="block-container"] { max-width:1180px !important; margin:auto !important; }
4
- h2, h3, .gr-markdown { color:#f0f6fc !important; font-weight:600 !important; }
5
-
6
- /* buttons / slider */
7
- .gr-button {
8
- border-radius:6px !important; font-weight:600 !important; height:52px !important;
9
- background:linear-gradient(90deg,#4f46e5,#6366f1) !important; border:none !important;
10
- box-shadow:0 2px 4px rgba(0,0,0,.25);
11
- }
12
- .gr-slider { height:52px !important; }
13
- .gr-slider input[type=range]::-webkit-slider-thumb { background:#6366f1 !important; }
14
-
15
- /* tables */
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; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/analysis_formatter.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utilities to render portfolio analysis output with styled HTML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from html import escape
7
+ from html.parser import HTMLParser
8
+ from typing import Iterable, List, Tuple
9
+
10
+ _SPAN_TAG = re.compile(r"</?span(?:\s+[^>]*?)?>", re.IGNORECASE)
11
+ _SPAN_ATTR = re.compile(r"([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*\"(.*?)\"")
12
+
13
+ ALLOWED_CLASSES = {
14
+ "analysis-container",
15
+ "analysis-output",
16
+ "analysis-status",
17
+ "analysis-line",
18
+ "analysis-keyword",
19
+ "analysis-caret",
20
+ "bullet",
21
+ "metric",
22
+ "metric-name",
23
+ "metric-separator",
24
+ "metric-value",
25
+ "negative",
26
+ "neutral",
27
+ "positive",
28
+ "section",
29
+ }
30
+
31
+ ALLOWED_TAGS = {"div", "p", "span", "h2", "h3", "ul", "ol", "li"}
32
+
33
+ SECTION_TITLES: Tuple[str, ...] = (
34
+ "Objective Evaluation",
35
+ "Risk Assessment",
36
+ "Interpretation",
37
+ "Recommendation",
38
+ )
39
+
40
+ KEYWORD_HIGHLIGHTS: Tuple[str, ...] = (
41
+ "poor performance",
42
+ "high risk",
43
+ "underperformed",
44
+ "volatility",
45
+ "recommendation",
46
+ "drawdown",
47
+ "exposure",
48
+ "opportunity",
49
+ )
50
+
51
+ METRIC_TOOLTIPS = {
52
+ "Sharpe Ratio": "Sharpe Ratio: excess return per unit of total risk.",
53
+ "Sortino Ratio": "Sortino Ratio: downside-risk-adjusted performance.",
54
+ "Calmar Ratio": "Calmar Ratio: annual return divided by max drawdown.",
55
+ "Max Drawdown": "Max Drawdown: largest observed portfolio loss from peak.",
56
+ "Beta": "Beta: sensitivity to benchmark movements.",
57
+ "Volatility": "Volatility: standard deviation of returns.",
58
+ }
59
+
60
+ _POSITIVE_HINTS = re.compile(
61
+ r"(\+|strong|improv|growth|positive|bullish|resilien|outperfor|favorable|advantage)",
62
+ re.IGNORECASE,
63
+ )
64
+ _NEGATIVE_HINTS = re.compile(
65
+ r"(-|poor|negative|risk|declin|drawdown|weak|bearish|loss|volatil|stress)",
66
+ re.IGNORECASE,
67
+ )
68
+ _KEYWORD_REGEX = re.compile(
69
+ "|".join(re.escape(word) for word in KEYWORD_HIGHLIGHTS), re.IGNORECASE
70
+ )
71
+ _METRIC_LINE = re.compile(r"^[-•]?\s*([^:]+?):\s*(.+)$")
72
+ _SECTION_HEADER = re.compile(r"^\*\*(.+?)\*\*")
73
+
74
+
75
+ def render_status_html(message: str) -> str:
76
+ """Render interim status or error messages."""
77
+ safe = escape(message)
78
+ body = f"<div class='analysis-output'><p class='analysis-status'>{safe}</p></div>"
79
+ return _wrap_with_container(body)
80
+
81
+
82
+ def render_analysis_html(text: str, show_caret: bool = False) -> str:
83
+ """Convert LLM response into themed HTML without inline styles."""
84
+ stripped = text.strip()
85
+ if not stripped:
86
+ html = _wrap_with_container("<div class='analysis-output'></div>")
87
+ return _append_caret(html) if show_caret else html
88
+
89
+ if _looks_like_html(stripped):
90
+ sanitized = _sanitize_analysis_html(stripped)
91
+ if sanitized.strip():
92
+ cleaned = _trim_trailing_breaks(sanitized).strip()
93
+ html = _wrap_with_container(cleaned)
94
+ return _append_caret(html) if show_caret else html
95
+
96
+ sections = _split_sections(stripped)
97
+ if not sections:
98
+ formatted_lines = _format_lines(stripped.splitlines())
99
+ body = "".join(formatted_lines)
100
+ html = _wrap_with_container(f"<div class='analysis-output'>{body}</div>")
101
+ return _append_caret(html) if show_caret else html
102
+
103
+ parts: List[str] = ["<div class='analysis-output'>"]
104
+ for idx, (title, content) in enumerate(sections):
105
+ parts.append("<div class='section'>")
106
+ parts.append(f"<h2>{escape(title)}</h2>")
107
+ formatted_lines = _format_lines(content.splitlines())
108
+ parts.extend(formatted_lines)
109
+ parts.append("</div>")
110
+ parts.append("</div>")
111
+ html = "".join(parts)
112
+ html = _wrap_with_container(_trim_trailing_breaks(html).strip())
113
+ return _append_caret(html) if show_caret else html
114
+
115
+
116
+ def _split_sections(text: str) -> List[Tuple[str, str]]:
117
+ sections: List[Tuple[str, str]] = []
118
+ current_title = None
119
+ buffer: List[str] = []
120
+
121
+ allowed_headers = {title.lower(): title for title in SECTION_TITLES}
122
+
123
+ for line in text.splitlines():
124
+ stripped = line.strip()
125
+ header_match = _SECTION_HEADER.match(stripped)
126
+ if header_match:
127
+ # flush previous section
128
+ if current_title and buffer:
129
+ sections.append((current_title, "\n".join(buffer).strip()))
130
+ buffer.clear()
131
+ matched_title = header_match.group(1).strip()
132
+ normalized = allowed_headers.get(matched_title.lower(), matched_title)
133
+ current_title = normalized
134
+ continue
135
+ if stripped in allowed_headers:
136
+ if current_title and buffer:
137
+ sections.append((current_title, "\n".join(buffer).strip()))
138
+ buffer.clear()
139
+ current_title = allowed_headers[stripped]
140
+ continue
141
+ buffer.append(line)
142
+
143
+ if current_title and buffer:
144
+ sections.append((current_title, "\n".join(buffer).strip()))
145
+ return sections
146
+
147
+
148
+ def _format_lines(lines: Iterable[str]) -> List[str]:
149
+ formatted: List[str] = []
150
+ for raw_line in lines:
151
+ line = raw_line.strip()
152
+ if not line:
153
+ continue
154
+
155
+ metric_match = _METRIC_LINE.match(line)
156
+ if metric_match:
157
+ formatted.append(_format_metric_line(metric_match.group(1), metric_match.group(2)))
158
+ continue
159
+
160
+ bullet = raw_line.lstrip().startswith(('-', '•'))
161
+ content = re.sub(r"^[-•]\s*", "", line) if bullet else line
162
+ paragraph = _decorate_text(content)
163
+ if bullet:
164
+ formatted.append(f"<p class='analysis-line bullet'>{paragraph}</p>")
165
+ else:
166
+ formatted.append(f"<p class='analysis-line'>{paragraph}</p>")
167
+ return formatted
168
+
169
+
170
+ def _format_metric_line(name: str, value: str) -> str:
171
+ value_class = _value_class(value)
172
+ tooltip = METRIC_TOOLTIPS.get(name.strip())
173
+ name_text = escape(name.strip())
174
+ name_span = (
175
+ f"<span class='metric-name' data-tooltip='{escape(tooltip)}'>{name_text}</span>"
176
+ if tooltip
177
+ else f"<span class='metric-name'>{name_text}</span>"
178
+ )
179
+ value_span = f"<span class='{value_class}'>{_decorate_text(value)}</span>"
180
+ return (
181
+ "<p class='analysis-line metric'>"
182
+ f"{name_span}<span class='metric-separator'>:</span>{value_span}"
183
+ "</p>"
184
+ )
185
+
186
+
187
+ def _decorate_text(text: str) -> str:
188
+ preserved = _preserve_spans(text)
189
+ if not preserved:
190
+ return ""
191
+ highlighted = _KEYWORD_REGEX.sub(
192
+ lambda match: f"<span class='analysis-keyword'>{match.group(0)}</span>", preserved
193
+ )
194
+ return highlighted
195
+
196
+
197
+ def _preserve_spans(text: str) -> str:
198
+ """Escape text while allowing limited span tags for inline emphasis."""
199
+
200
+ result: List[str] = []
201
+ last_index = 0
202
+ for match in _SPAN_TAG.finditer(text):
203
+ start, end = match.span()
204
+ if start > last_index:
205
+ result.append(escape(text[last_index:start]))
206
+ result.append(_sanitize_span(match.group(0)))
207
+ last_index = end
208
+ if last_index < len(text):
209
+ result.append(escape(text[last_index:]))
210
+ return "".join(result)
211
+
212
+
213
+ def _sanitize_span(tag: str) -> str:
214
+ if tag.startswith("</"):
215
+ return "</span>"
216
+
217
+ attributes = {}
218
+ for attr, value in _SPAN_ATTR.findall(tag):
219
+ if attr.lower() != "class":
220
+ continue
221
+ filtered = _filter_allowed_classes(value)
222
+ if filtered:
223
+ attributes["class"] = filtered
224
+
225
+ attr_string = "".join(
226
+ f" {name}=\"{escape(val)}\"" for name, val in attributes.items()
227
+ )
228
+ return f"<span{attr_string}>"
229
+
230
+
231
+ def _filter_allowed_classes(raw_value: str) -> str:
232
+ classes = [cls for cls in raw_value.split() if cls in ALLOWED_CLASSES]
233
+ return " ".join(dict.fromkeys(classes))
234
+
235
+
236
+ def _looks_like_html(text: str) -> bool:
237
+ return bool(re.search(r"<\s*(div|p|span|h2|h3|ul|ol|li)\b", text, re.IGNORECASE))
238
+
239
+
240
+ class _AnalyzerHTMLSanitizer(HTMLParser):
241
+ def __init__(self) -> None:
242
+ super().__init__()
243
+ self.parts: List[str] = []
244
+ self._open_tags: List[str] = []
245
+
246
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, str]]) -> None:
247
+ tag_lower = tag.lower()
248
+ if tag_lower not in ALLOWED_TAGS:
249
+ self._open_tags.append("")
250
+ return
251
+
252
+ attr_string = ""
253
+ if attrs:
254
+ allowed_attrs = []
255
+ for name, value in attrs:
256
+ name_lower = name.lower()
257
+ if name_lower == "class":
258
+ filtered = _filter_allowed_classes(value)
259
+ if filtered:
260
+ allowed_attrs.append(("class", filtered))
261
+ if allowed_attrs:
262
+ attr_string = "".join(
263
+ f" {escape(attr)}=\"{escape(val)}\"" for attr, val in allowed_attrs
264
+ )
265
+
266
+ self.parts.append(f"<{tag_lower}{attr_string}>")
267
+ self._open_tags.append(tag_lower)
268
+
269
+ def handle_endtag(self, tag: str) -> None:
270
+ if not self._open_tags:
271
+ return
272
+ open_tag = self._open_tags.pop()
273
+ if open_tag:
274
+ self.parts.append(f"</{open_tag}>")
275
+
276
+ def handle_data(self, data: str) -> None:
277
+ if data:
278
+ self.parts.append(escape(data))
279
+
280
+ def handle_entityref(self, name: str) -> None:
281
+ self.parts.append(f"&{name};")
282
+
283
+ def handle_charref(self, name: str) -> None:
284
+ self.parts.append(f"&#{name};")
285
+
286
+
287
+ def _sanitize_analysis_html(text: str) -> str:
288
+ sanitizer = _AnalyzerHTMLSanitizer()
289
+ sanitizer.feed(text)
290
+ sanitizer.close()
291
+ sanitized = "".join(sanitizer.parts)
292
+ return re.sub(r"<style.*?>.*?</style>", "", sanitized, flags=re.IGNORECASE | re.DOTALL)
293
+
294
+
295
+ def _value_class(value: str) -> str:
296
+ if _NEGATIVE_HINTS.search(value):
297
+ return "metric-value negative"
298
+ if _POSITIVE_HINTS.search(value):
299
+ return "metric-value positive"
300
+ return "metric-value neutral"
301
+
302
+
303
+ def _trim_trailing_breaks(html: str) -> str:
304
+ return re.sub(r"(?:<br\s*/?>\s*)+$", "", html)
305
+
306
+
307
+ def _wrap_with_container(body: str) -> str:
308
+ """Ensure the analysis output is wrapped in the themed container."""
309
+
310
+ if re.search(r"class\s*=\s*['\"]analysis-container['\"]", body):
311
+ return body
312
+ return f"<div class='analysis-container'>{body}</div>"
313
+
314
+
315
+ def _append_caret(html: str) -> str:
316
+ """Append a blinking caret to indicate streaming output."""
317
+
318
+ caret = "<span class='analysis-caret'>|</span>"
319
+ if caret in html:
320
+ return html
321
+ updated = re.sub(r"(</div>\s*</div>\s*)$", caret + r"\1", html, count=1)
322
+ if updated == html:
323
+ return html + caret
324
+ return updated
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] = []
presentation/styles/themes/base.css ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* === Global Layout === */
2
+ .gradio-container { font-family: 'Inter', sans-serif; background:#0d1117 !important; }
3
+ [data-testid="block-container"] { max-width:1180px !important; margin:auto !important; }
4
+ h2, h3, .gr-markdown { color:#f0f6fc !important; font-weight:600 !important; }
5
+
6
+ /* analysis output styling */
7
+ #analysis_output {
8
+ display:flex;
9
+ justify-content:center;
10
+ padding:0 16px;
11
+ box-sizing:border-box;
12
+ font-family:'Inter','Segoe UI',sans-serif;
13
+ color:#f0f6fc;
14
+ }
15
+
16
+ #analysis_output .analysis-container {
17
+ max-width:900px;
18
+ width:100%;
19
+ margin:0 auto;
20
+ padding:20px 24px;
21
+ border-radius:10px;
22
+ border:1px solid #1f2937;
23
+ background-color:#0d1117;
24
+ box-shadow:0 0 8px rgba(0,0,0,0.3);
25
+ box-sizing:border-box;
26
+ color:#f0f6fc;
27
+ }
28
+
29
+ #analysis_output .analysis-output {
30
+ width:100%;
31
+ font-size:15px;
32
+ line-height:1.6;
33
+ }
34
+
35
+ #analysis_output .analysis-output .section {
36
+ margin-top:30px;
37
+ margin-bottom:24px;
38
+ }
39
+
40
+ #analysis_output .analysis-output .section:first-of-type {
41
+ margin-top:0;
42
+ }
43
+
44
+ #analysis_output .analysis-output .section:last-of-type {
45
+ margin-bottom:0;
46
+ }
47
+
48
+ #analysis_output .analysis-output .section h2 {
49
+ color:#58a6ff;
50
+ text-align:center;
51
+ margin:0 0 10px;
52
+ font-size:1.15rem;
53
+ font-weight:600;
54
+ }
55
+
56
+ #analysis_output .analysis-output .section:not(:last-child)::after {
57
+ content:"";
58
+ display:block;
59
+ border-bottom:1px solid #30363d;
60
+ margin:22px 0 0;
61
+ }
62
+
63
+ #analysis_output p,
64
+ #analysis_output span,
65
+ #analysis_output div {
66
+ word-spacing:normal !important;
67
+ letter-spacing:normal !important;
68
+ white-space:normal !important;
69
+ }
70
+
71
+ #analysis_output p,
72
+ #analysis_output li {
73
+ margin:8px 0;
74
+ color:#9ca3af;
75
+ text-align:justify;
76
+ text-justify:inter-word;
77
+ line-height:1.6;
78
+ text-wrap:balance;
79
+ hyphens:auto;
80
+ word-break:normal;
81
+ }
82
+
83
+ #analysis_output ul,
84
+ #analysis_output ol {
85
+ margin:8px 0 8px 20px;
86
+ padding-left:4px;
87
+ color:#9ca3af;
88
+ }
89
+
90
+ #analysis_output .analysis-line {
91
+ margin:6px 0;
92
+ width:100%;
93
+ }
94
+
95
+ #analysis_output .analysis-line + .analysis-line {
96
+ margin-top:8px;
97
+ }
98
+
99
+ #analysis_output .analysis-line.metric {
100
+ display:block;
101
+ }
102
+
103
+ #analysis_output .analysis-line.bullet {
104
+ position:relative;
105
+ padding-left:18px;
106
+ }
107
+
108
+ #analysis_output .analysis-line.bullet::before {
109
+ content:"•";
110
+ color:#58a6ff;
111
+ position:absolute;
112
+ left:0;
113
+ top:0;
114
+ }
115
+
116
+ .metric-name {
117
+ font-family:'JetBrains Mono','Fira Code','Source Code Pro',monospace;
118
+ font-weight:600;
119
+ color:#9ca3af;
120
+ display:inline !important;
121
+ }
122
+
123
+ .metric-name[data-tooltip] {
124
+ position:relative;
125
+ cursor:help;
126
+ }
127
+
128
+ .metric-name[data-tooltip]::after {
129
+ content:attr(data-tooltip);
130
+ position:absolute;
131
+ left:0;
132
+ bottom:120%;
133
+ background:#111827;
134
+ border:1px solid #1f2937;
135
+ padding:6px 8px;
136
+ font-size:12px;
137
+ line-height:1.35;
138
+ color:#cbd5f5;
139
+ border-radius:6px;
140
+ white-space:nowrap;
141
+ opacity:0;
142
+ transform:translateY(6px);
143
+ pointer-events:none;
144
+ transition:opacity 0.15s ease, transform 0.15s ease;
145
+ z-index:20;
146
+ }
147
+
148
+ .metric-name[data-tooltip]:hover::after {
149
+ opacity:1;
150
+ transform:translateY(0);
151
+ }
152
+
153
+ .metric-separator {
154
+ color:#293548;
155
+ margin:0 6px;
156
+ }
157
+
158
+ .metric-value {
159
+ font-family:'JetBrains Mono','Fira Code','Source Code Pro',monospace;
160
+ font-weight:500;
161
+ margin-left:6px;
162
+ display:inline !important;
163
+ }
164
+
165
+
166
+ .metric-value.positive { color:#4ade80; }
167
+ .metric-value.negative { color:#f87171; }
168
+ .metric-value.neutral { color:#9ca3af; }
169
+
170
+ #analysis_output .analysis-keyword {
171
+ color:#93c5fd;
172
+ font-weight:600;
173
+ }
174
+
175
+ #analysis_output .analysis-status {
176
+ color:#cbd5f5;
177
+ font-size:15px;
178
+ margin:0;
179
+ text-align:center;
180
+ }
181
+
182
+ .analysis-caret {
183
+ display:inline-block;
184
+ width:2px;
185
+ height:1.2em;
186
+ margin-left:6px;
187
+ background:#58a6ff;
188
+ animation:analysisCaretBlink 1s steps(1) infinite;
189
+ vertical-align:baseline;
190
+ }
191
+
192
+ @keyframes analysisCaretBlink {
193
+ 0%, 49% { opacity:1; }
194
+ 50%, 100% { opacity:0; }
195
+ }
196
+
197
+ @media (max-width: 860px) {
198
+ #analysis_output {
199
+ padding:0 12px;
200
+ }
201
+
202
+ #analysis_output .analysis-container {
203
+ padding:18px 20px;
204
+ border-radius:10px;
205
+ }
206
+ }
207
+
208
+ /* buttons / slider */
209
+ .gr-button {
210
+ border-radius:6px !important; font-weight:600 !important; height:52px !important;
211
+ background:linear-gradient(90deg,#4f46e5,#6366f1) !important; border:none !important;
212
+ box-shadow:0 2px 4px rgba(0,0,0,.25);
213
+ }
214
+ .gr-slider { height:52px !important; }
215
+ .gr-slider input[type=range]::-webkit-slider-thumb { background:#6366f1 !important; }
216
+
217
+ /* tables */
218
+ .gr-dataframe table { width:100% !important; color:#c9d1d9 !important; background:#161b22 !important; }
219
+ .gr-dataframe th { background:#21262d !important; color:#f0f6fc !important; border-bottom:1px solid #30363d !important; }
220
+ .gr-dataframe td { border-top:1px solid #30363d !important; padding:8px !important; }
221
+
222
+ #comparison_table table { table-layout:fixed; }
223
+ #comparison_table table th:nth-child(1),
224
+ #comparison_table table td:nth-child(1) { width:40% !important; }
225
+ #comparison_table table th:nth-child(2),
226
+ #comparison_table table td:nth-child(2),
227
+ #comparison_table table th:nth-child(3),
228
+ #comparison_table table td:nth-child(3),
229
+ #comparison_table table th:nth-child(4),
230
+ #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
prompts/reference_templates.py CHANGED
@@ -7,19 +7,14 @@ Purpose: Defines reusable prompt templates for portfolio analysis and comparison
7
  """
8
 
9
  REFERENCE_PROMPT = (
10
- "**Analysis**\n\n"
11
- "For each key metric, provide value and brief interpretation:\n"
12
- "- Annualized Return: {value1}\n"
13
- "- Max Drawdown: {value2}\n"
14
- "- Sharpe Ratio: {value3}\n"
15
- "- Sortino Ratio: {value4}\n"
16
- "- Calmar Ratio: {value5}\n"
17
- "- Beta: {value6}\n"
18
- "- Volatility: {value7}\n\n"
19
- "**Interpretation**\n"
20
- "Summarize the overall strategy characteristics, risk level, and stability.\n\n"
21
- "**Recommendation**\n"
22
- "Provide a brief conclusion about the portfolio’s viability."
23
  )
24
 
25
  REFERENCE_COMPARISON_PROMPT = (
 
7
  """
8
 
9
  REFERENCE_PROMPT = (
10
+ "Objective Evaluation\n"
11
+ "Provide metric-level commentary for return, drawdown, Sharpe, Sortino, Calmar, beta, and volatility.\n\n"
12
+ "Risk Assessment\n"
13
+ "Explain downside risks, drawdown behaviour, and volatility stability.\n\n"
14
+ "Interpretation\n"
15
+ "Summarize the portfolio’s style, consistency, and resilience based on the figures.\n\n"
16
+ "Recommendation\n"
17
+ "Conclude with a concise actionable insight rooted in the supplied data."
 
 
 
 
 
18
  )
19
 
20
  REFERENCE_COMPARISON_PROMPT = (
prompts/system_prompts.py CHANGED
@@ -9,14 +9,23 @@ Purpose: Stores system-level instructions for LLM.
9
  ANALYSIS_SYSTEM_PROMPT = """
10
  You are a quantitative portfolio analyst.
11
 
12
- Given a single portfolio’s metrics, provide:
13
- 1️⃣ **Objective evaluation** of performance (returns, alpha, Sharpe ratio).
14
- 2️⃣ **Risk assessment** (volatility, max drawdown).
15
- 3️⃣ **Interpretation of efficiency** (how well risk and return balance).
16
- 4️⃣ **Concise recommendation** — state whether the portfolio is performing *well, neutrally, or poorly*, and what could be improved.
17
-
18
- Avoid general statements. Use data context.
19
- Your tone: professional, precise, analytical.
 
 
 
 
 
 
 
 
 
20
  """
21
 
22
  COMPARISON_SYSTEM_PROMPT = """
 
9
  ANALYSIS_SYSTEM_PROMPT = """
10
  You are a quantitative portfolio analyst.
11
 
12
+ Produce a structured plain-text report using exactly four sections in this order:
13
+ Objective Evaluation
14
+ Risk Assessment
15
+ Interpretation
16
+ Recommendation
17
+
18
+ Formatting requirements:
19
+ - Place each section title on its own line exactly as written above.
20
+ - Follow every title with metric lines formatted as "Metric Name: value — brief interpretation".
21
+ - Keep the metric name, colon, value, and commentary on a single line with natural single-spacing.
22
+ - Avoid HTML tags, Markdown headings, tables, bullets, tabs, or extra spaces.
23
+ - After the key metrics in each section, add 1–2 concise sentences (around 80–100 characters) explaining the implications.
24
+ - Do not end the response with blank lines.
25
+
26
+ Analytical guidance:
27
+ - Base the commentary strictly on the provided portfolio metrics.
28
+ - Highlight both strengths and weaknesses, including return efficiency, risk exposure, and stability.
29
  """
30
 
31
  COMPARISON_SYSTEM_PROMPT = """
services/__init__.py DELETED
@@ -1,2 +0,0 @@
1
- # __init__.py
2
- # Marks this directory as a Python package.