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

Add caching and UI updates for portfolio insights

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