@drico2008/fincli 0.1.9 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +909 -718
  3. package/fincli/__init__.py +3 -3
  4. package/fincli/app/agents/__init__.py +5 -0
  5. package/fincli/app/agents/registry.py +76 -0
  6. package/fincli/app/analysis/ai_prompts.py +23 -16
  7. package/fincli/app/analysis/analyzer.py +107 -100
  8. package/fincli/app/analysis/assistant_context.py +187 -186
  9. package/fincli/app/analysis/backtest.py +179 -0
  10. package/fincli/app/analysis/gameplay_plan.py +79 -0
  11. package/fincli/app/analysis/multi_timeframe.py +180 -0
  12. package/fincli/app/analysis/trading_methods.py +144 -0
  13. package/fincli/app/cli/commands.py +105 -83
  14. package/fincli/app/cli/router.py +2123 -1294
  15. package/fincli/app/connectors/__init__.py +5 -0
  16. package/fincli/app/connectors/catalog.py +148 -0
  17. package/fincli/app/connectors/news_connectors.py +412 -0
  18. package/fincli/app/modules/alerts.py +80 -0
  19. package/fincli/app/modules/economic_calendar.py +374 -1
  20. package/fincli/app/modules/reports.py +151 -0
  21. package/fincli/app/modules/scanner.py +111 -93
  22. package/fincli/app/modules/transactions.py +84 -84
  23. package/fincli/app/modules/user_profile.py +84 -0
  24. package/fincli/app/plugins/loader.py +72 -0
  25. package/fincli/app/providers/ai/manager.py +60 -60
  26. package/fincli/app/providers/market/alphavantage_provider.py +194 -0
  27. package/fincli/app/providers/market/base.py +98 -77
  28. package/fincli/app/providers/market/custom_provider.py +186 -169
  29. package/fincli/app/providers/market/manager.py +84 -1
  30. package/fincli/app/providers/market/symbols.py +143 -0
  31. package/fincli/app/providers/market/twelvedata_provider.py +167 -167
  32. package/fincli/app/research/__init__.py +7 -0
  33. package/fincli/app/research/engine.py +75 -0
  34. package/fincli/app/research/formatter.py +22 -0
  35. package/fincli/app/research/models.py +18 -0
  36. package/fincli/app/research/prompt_builder.py +47 -0
  37. package/fincli/app/services/macro_data.py +50 -0
  38. package/fincli/app/services/market_data.py +203 -203
  39. package/fincli/app/services/news_aggregator.py +90 -0
  40. package/fincli/app/services/web_research.py +267 -267
  41. package/fincli/app/storage/config.py +122 -88
  42. package/fincli/app/storage/database.py +200 -101
  43. package/fincli/app/storage/secrets.py +8 -2
  44. package/fincli/app/tui/components.py +68 -50
  45. package/fincli/app/tui/layout.py +269 -258
  46. package/fincli/app/tui/market_provider_selector.py +3 -1
  47. package/fincli/app/tui/theme.py +134 -74
  48. package/fincli/app/utils/formatting.py +123 -60
  49. package/package.json +23 -23
  50. package/pyproject.toml +35 -35
@@ -1,167 +1,167 @@
1
- """Twelve Data provider for multi-asset market data."""
2
-
3
- from __future__ import annotations
4
-
5
- from datetime import datetime
6
- from typing import Any
7
-
8
- import httpx
9
-
10
- from fincli.app.providers.market.base import Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
11
- from fincli.app.providers.market.symbols import resolve_twelvedata_symbol
12
- from fincli.app.utils.errors import ProviderError, RateLimitError
13
-
14
-
15
- class TwelveDataProvider:
16
- name = "twelvedata"
17
-
18
- def __init__(
19
- self,
20
- api_key: str | None,
21
- base_url: str = "https://api.twelvedata.com",
22
- client: httpx.AsyncClient | None = None,
23
- ) -> None:
24
- self.api_key = api_key or ""
25
- self.base_url = base_url.rstrip("/")
26
- self._client = client
27
-
28
- async def quote(self, symbol: str) -> Quote:
29
- resolved = resolve_twelvedata_symbol(symbol)
30
- data = await self._get("/quote", {"symbol": resolved.symbol})
31
- price = _safe_float(data.get("close") or data.get("price"))
32
- if price is None:
33
- raise ProviderError(f"Twelve Data tidak mengembalikan quote valid untuk {symbol}.")
34
- return Quote(
35
- symbol=str(data.get("symbol") or resolved.symbol).upper(),
36
- price=price,
37
- currency=str(data.get("currency") or "USD"),
38
- provider=self.name,
39
- timestamp=_parse_datetime(data.get("datetime")) or datetime.now(),
40
- status="realtime",
41
- )
42
-
43
- async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
44
- resolved = resolve_twelvedata_symbol(symbol)
45
- data = await self._get(
46
- "/time_series",
47
- {
48
- "symbol": resolved.symbol,
49
- "interval": _interval_to_twelvedata(interval),
50
- "outputsize": _period_to_outputsize(period, interval),
51
- "timezone": "UTC",
52
- },
53
- )
54
- values = data.get("values") if isinstance(data, dict) else None
55
- if not isinstance(values, list) or not values:
56
- message = data.get("message") if isinstance(data, dict) else None
57
- raise ProviderError(f"Twelve Data OHLCV kosong untuk {symbol}.", str(message) if message else None)
58
-
59
- candles = [_parse_candle(item) for item in values if isinstance(item, dict)]
60
- candles.sort(key=lambda candle: candle.timestamp)
61
- if not candles:
62
- raise ProviderError(f"Twelve Data OHLCV kosong untuk {symbol}.")
63
- return candles
64
-
65
- async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
66
- return []
67
-
68
- async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
69
- resolved = resolve_twelvedata_symbol(symbol)
70
- return FundamentalSnapshot(symbol=resolved.symbol.upper(), provider=self.name, currency="USD")
71
-
72
- async def status(self) -> ProviderStatus:
73
- status = "configured" if self.api_key else "unavailable"
74
- message = "Twelve Data provider configured." if self.api_key else "Requires TWELVE_DATA_API_KEY."
75
- return ProviderStatus(name=self.name, realtime=True, status=status, message=message)
76
-
77
- async def _get(self, path: str, params: dict[str, object]) -> Any:
78
- if not self.api_key:
79
- raise ProviderError("API key Twelve Data belum diatur.", "Gunakan /news_model key twelvedata <api_key>.")
80
- close_client = self._client is None
81
- client = self._client or httpx.AsyncClient(timeout=30)
82
- try:
83
- response = await client.get(f"{self.base_url}{path}", params={**params, "apikey": self.api_key})
84
- if response.status_code == 429:
85
- raise RateLimitError("Twelve Data terkena rate limit.")
86
- response.raise_for_status()
87
- data = response.json()
88
- if isinstance(data, dict) and data.get("status") == "error":
89
- raise ProviderError(f"Twelve Data gagal: {data.get('message') or 'unknown error'}")
90
- return data
91
- except httpx.TimeoutException as exc:
92
- raise ProviderError("Twelve Data timeout.") from exc
93
- except httpx.HTTPStatusError as exc:
94
- raise ProviderError(f"Twelve Data gagal: HTTP {exc.response.status_code}.") from exc
95
- except ValueError as exc:
96
- raise ProviderError("Response Twelve Data bukan JSON valid.") from exc
97
- finally:
98
- if close_client:
99
- await client.aclose()
100
-
101
-
102
- def _parse_candle(item: dict[str, Any]) -> Candle:
103
- return Candle(
104
- timestamp=_parse_datetime(item.get("datetime")) or datetime.now(),
105
- open=float(item["open"]),
106
- high=float(item["high"]),
107
- low=float(item["low"]),
108
- close=float(item["close"]),
109
- volume=float(item.get("volume") or 0),
110
- )
111
-
112
-
113
- def _safe_float(value: Any) -> float | None:
114
- try:
115
- if value is None:
116
- return None
117
- return float(value)
118
- except (TypeError, ValueError):
119
- return None
120
-
121
-
122
- def _parse_datetime(value: object) -> datetime | None:
123
- if not value:
124
- return None
125
- text = str(value).replace("Z", "+00:00")
126
- for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
127
- try:
128
- return datetime.strptime(text, fmt)
129
- except ValueError:
130
- continue
131
- try:
132
- return datetime.fromisoformat(text)
133
- except ValueError:
134
- return None
135
-
136
-
137
- def _interval_to_twelvedata(interval: str) -> str:
138
- mapping = {
139
- "1m": "1min",
140
- "5m": "5min",
141
- "15m": "15min",
142
- "30m": "30min",
143
- "1h": "1h",
144
- "4h": "4h",
145
- "1d": "1day",
146
- "d": "1day",
147
- "1w": "1week",
148
- "w": "1week",
149
- "1mo": "1month",
150
- }
151
- return mapping.get(interval.lower(), interval)
152
-
153
-
154
- def _period_to_outputsize(period: str, interval: str) -> int:
155
- normalized = period.lower()
156
- interval_normalized = interval.lower()
157
- if normalized.endswith("mo"):
158
- days = 30 * int(normalized[:-2] or 6)
159
- elif normalized.endswith("y"):
160
- days = 365 * int(normalized[:-1] or 1)
161
- elif normalized.endswith("d"):
162
- days = int(normalized[:-1] or 180)
163
- else:
164
- days = 180
165
- if interval_normalized in {"1m", "5m", "15m", "30m", "1h", "4h"}:
166
- return min(5000, max(120, days * 24))
167
- return min(5000, max(30, days))
1
+ """Twelve Data provider for multi-asset market data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from fincli.app.providers.market.base import Candle, FundamentalSnapshot, NewsItem, ProviderStatus, Quote
11
+ from fincli.app.providers.market.symbols import resolve_twelvedata_symbol
12
+ from fincli.app.utils.errors import ProviderError, RateLimitError
13
+
14
+
15
+ class TwelveDataProvider:
16
+ name = "twelvedata"
17
+
18
+ def __init__(
19
+ self,
20
+ api_key: str | None,
21
+ base_url: str = "https://api.twelvedata.com",
22
+ client: httpx.AsyncClient | None = None,
23
+ ) -> None:
24
+ self.api_key = api_key or ""
25
+ self.base_url = base_url.rstrip("/")
26
+ self._client = client
27
+
28
+ async def quote(self, symbol: str) -> Quote:
29
+ resolved = resolve_twelvedata_symbol(symbol)
30
+ data = await self._get("/quote", {"symbol": resolved.symbol})
31
+ price = _safe_float(data.get("close") or data.get("price"))
32
+ if price is None:
33
+ raise ProviderError(f"Twelve Data tidak mengembalikan quote valid untuk {symbol}.")
34
+ return Quote(
35
+ symbol=str(data.get("symbol") or resolved.symbol).upper(),
36
+ price=price,
37
+ currency=str(data.get("currency") or "USD"),
38
+ provider=self.name,
39
+ timestamp=_parse_datetime(data.get("datetime")) or datetime.now(),
40
+ status="realtime",
41
+ )
42
+
43
+ async def history(self, symbol: str, period: str = "6mo", interval: str = "1d") -> list[Candle]:
44
+ resolved = resolve_twelvedata_symbol(symbol)
45
+ data = await self._get(
46
+ "/time_series",
47
+ {
48
+ "symbol": resolved.symbol,
49
+ "interval": _interval_to_twelvedata(interval),
50
+ "outputsize": _period_to_outputsize(period, interval),
51
+ "timezone": "UTC",
52
+ },
53
+ )
54
+ values = data.get("values") if isinstance(data, dict) else None
55
+ if not isinstance(values, list) or not values:
56
+ message = data.get("message") if isinstance(data, dict) else None
57
+ raise ProviderError(f"Twelve Data OHLCV kosong untuk {symbol}.", str(message) if message else None)
58
+
59
+ candles = [_parse_candle(item) for item in values if isinstance(item, dict)]
60
+ candles.sort(key=lambda candle: candle.timestamp)
61
+ if not candles:
62
+ raise ProviderError(f"Twelve Data OHLCV kosong untuk {symbol}.")
63
+ return candles
64
+
65
+ async def news(self, symbol: str, limit: int = 5) -> list[NewsItem]:
66
+ return []
67
+
68
+ async def fundamentals(self, symbol: str) -> FundamentalSnapshot:
69
+ resolved = resolve_twelvedata_symbol(symbol)
70
+ return FundamentalSnapshot(symbol=resolved.symbol.upper(), provider=self.name, currency="USD")
71
+
72
+ async def status(self) -> ProviderStatus:
73
+ status = "configured" if self.api_key else "unavailable"
74
+ message = "Twelve Data provider configured." if self.api_key else "Requires TWELVE_DATA_API_KEY."
75
+ return ProviderStatus(name=self.name, realtime=True, status=status, message=message)
76
+
77
+ async def _get(self, path: str, params: dict[str, object]) -> Any:
78
+ if not self.api_key:
79
+ raise ProviderError("API key Twelve Data belum diatur.", "Gunakan /news_model key twelvedata <api_key>.")
80
+ close_client = self._client is None
81
+ client = self._client or httpx.AsyncClient(timeout=30)
82
+ try:
83
+ response = await client.get(f"{self.base_url}{path}", params={**params, "apikey": self.api_key})
84
+ if response.status_code == 429:
85
+ raise RateLimitError("Twelve Data terkena rate limit.")
86
+ response.raise_for_status()
87
+ data = response.json()
88
+ if isinstance(data, dict) and data.get("status") == "error":
89
+ raise ProviderError(f"Twelve Data gagal: {data.get('message') or 'unknown error'}")
90
+ return data
91
+ except httpx.TimeoutException as exc:
92
+ raise ProviderError("Twelve Data timeout.") from exc
93
+ except httpx.HTTPStatusError as exc:
94
+ raise ProviderError(f"Twelve Data gagal: HTTP {exc.response.status_code}.") from exc
95
+ except ValueError as exc:
96
+ raise ProviderError("Response Twelve Data bukan JSON valid.") from exc
97
+ finally:
98
+ if close_client:
99
+ await client.aclose()
100
+
101
+
102
+ def _parse_candle(item: dict[str, Any]) -> Candle:
103
+ return Candle(
104
+ timestamp=_parse_datetime(item.get("datetime")) or datetime.now(),
105
+ open=float(item["open"]),
106
+ high=float(item["high"]),
107
+ low=float(item["low"]),
108
+ close=float(item["close"]),
109
+ volume=float(item.get("volume") or 0),
110
+ )
111
+
112
+
113
+ def _safe_float(value: Any) -> float | None:
114
+ try:
115
+ if value is None:
116
+ return None
117
+ return float(value)
118
+ except (TypeError, ValueError):
119
+ return None
120
+
121
+
122
+ def _parse_datetime(value: object) -> datetime | None:
123
+ if not value:
124
+ return None
125
+ text = str(value).replace("Z", "+00:00")
126
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
127
+ try:
128
+ return datetime.strptime(text, fmt)
129
+ except ValueError:
130
+ continue
131
+ try:
132
+ return datetime.fromisoformat(text)
133
+ except ValueError:
134
+ return None
135
+
136
+
137
+ def _interval_to_twelvedata(interval: str) -> str:
138
+ mapping = {
139
+ "1m": "1min",
140
+ "5m": "5min",
141
+ "15m": "15min",
142
+ "30m": "30min",
143
+ "1h": "1h",
144
+ "4h": "4h",
145
+ "1d": "1day",
146
+ "d": "1day",
147
+ "1w": "1week",
148
+ "w": "1week",
149
+ "1mo": "1month",
150
+ }
151
+ return mapping.get(interval.lower(), interval)
152
+
153
+
154
+ def _period_to_outputsize(period: str, interval: str) -> int:
155
+ normalized = period.lower()
156
+ interval_normalized = interval.lower()
157
+ if normalized.endswith("mo"):
158
+ days = 30 * int(normalized[:-2] or 6)
159
+ elif normalized.endswith("y"):
160
+ days = 365 * int(normalized[:-1] or 1)
161
+ elif normalized.endswith("d"):
162
+ days = int(normalized[:-1] or 180)
163
+ else:
164
+ days = 180
165
+ if interval_normalized in {"1m", "5m", "15m", "30m", "1h", "4h"}:
166
+ return min(5000, max(120, days * 24))
167
+ return min(5000, max(30, days))
@@ -0,0 +1,7 @@
1
+ """Research workspace package."""
2
+
3
+ from fincli.app.research.engine import ResearchEngine
4
+ from fincli.app.research.formatter import format_research_brief
5
+ from fincli.app.research.models import ResearchBrief
6
+
7
+ __all__ = ["ResearchBrief", "ResearchEngine", "format_research_brief"]
@@ -0,0 +1,75 @@
1
+ """Research workspace orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.providers.ai.base import AIRequest, BaseAIProvider
6
+ from fincli.app.research.models import ResearchBrief
7
+ from fincli.app.research.prompt_builder import build_research_prompt
8
+ from fincli.app.services.market_data import MarketDataService
9
+ from fincli.app.services.market_overview import MarketOverview, build_market_overview
10
+
11
+
12
+ class ResearchEngine:
13
+ """Build compact research briefs around the existing market overview service."""
14
+
15
+ def __init__(self, market_service: MarketDataService, ai_provider: BaseAIProvider | None = None, model: str = "") -> None:
16
+ self.market_service = market_service
17
+ self.ai_provider = ai_provider
18
+ self.model = model
19
+
20
+ async def build(self, symbol: str, timeframe: str = "1d", mode: str = "quick") -> ResearchBrief:
21
+ overview = await build_market_overview(symbol.upper(), self.market_service, timeframe)
22
+ brief = _brief_from_overview(overview, mode)
23
+ if mode == "deep" and self.ai_provider is not None:
24
+ prompt = build_research_prompt(brief)
25
+ response = await self.ai_provider.complete(AIRequest(prompt=prompt, model=self.model))
26
+ return ResearchBrief(
27
+ symbol=brief.symbol,
28
+ mode=brief.mode,
29
+ overview=brief.overview,
30
+ decision_points=brief.decision_points,
31
+ risks=brief.risks,
32
+ final_summary=brief.final_summary,
33
+ ai_summary=response.content,
34
+ )
35
+ return brief
36
+
37
+
38
+ def _brief_from_overview(overview: MarketOverview, mode: str) -> ResearchBrief:
39
+ technical = overview.technical
40
+ structure = overview.structure
41
+ fundamentals = overview.fundamentals
42
+ decision_points = [
43
+ f"Price {overview.quote.price} {overview.quote.currency} via {overview.quote.provider} ({overview.quote.status}).",
44
+ f"Trend bias {technical.trend_bias}; structure {structure.trend} with {structure.latest_pattern}.",
45
+ f"Key levels: support {technical.support}, resistance {technical.resistance}, ATR {technical.atr}.",
46
+ f"Momentum: RSI {technical.rsi}, MACD {technical.macd}/{technical.macd_signal}.",
47
+ ]
48
+ if fundamentals is not None:
49
+ decision_points.append(
50
+ f"Fundamentals: P/E {fundamentals.pe_ratio}, EPS {fundamentals.eps}, sector {fundamentals.sector or 'N/A'}."
51
+ )
52
+ if overview.news:
53
+ decision_points.append(f"Latest news: {overview.news[0].title} ({overview.news[0].source}).")
54
+
55
+ risks = [
56
+ f"Data quality {overview.data_quality.score}/100; provider label {overview.data_quality.provider}.",
57
+ "Use confirmation and invalidation; do not treat this brief as financial advice.",
58
+ ]
59
+ if structure.change_of_character:
60
+ risks.append("Change of character detected; directional confidence should be reduced.")
61
+ if technical.rsi is not None and (technical.rsi > 75 or technical.rsi < 25):
62
+ risks.append("RSI is at an extreme; avoid chasing without confirmation.")
63
+
64
+ final_summary = (
65
+ f"{overview.symbol} is a {technical.trend_bias} / {structure.trend} setup with "
66
+ f"{overview.data_quality.score}/100 data quality. Focus on support/resistance reaction and news/fundamental confirmation."
67
+ )
68
+ return ResearchBrief(
69
+ symbol=overview.symbol,
70
+ mode=mode,
71
+ overview=overview,
72
+ decision_points=decision_points[:6],
73
+ risks=risks[:4],
74
+ final_summary=final_summary,
75
+ )
@@ -0,0 +1,22 @@
1
+ """Rich renderers for research workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.table import Table
6
+
7
+ from fincli.app.research.models import ResearchBrief
8
+ from fincli.app.utils.formatting import semantic_text
9
+
10
+
11
+ def format_research_brief(brief: ResearchBrief) -> Table:
12
+ table = Table(title=f"Research Brief: {brief.symbol} | {brief.mode}", expand=True)
13
+ table.add_column("Section", style="cyan", no_wrap=True)
14
+ table.add_column("Description", overflow="fold")
15
+ table.add_row("Data Quality", semantic_text(f"{brief.overview.data_quality.score}/100 | {brief.overview.data_quality.provider}"))
16
+ table.add_row("Decision Points", semantic_text("\n".join(f"- {point}" for point in brief.decision_points)))
17
+ table.add_row("Risks", semantic_text("\n".join(f"- {risk}" for risk in brief.risks)))
18
+ if brief.ai_summary:
19
+ table.add_row("AI Summary", brief.ai_summary)
20
+ table.add_row("Final Summary", semantic_text(brief.final_summary))
21
+ table.caption = "Research output is informational only, not financial advice."
22
+ return table
@@ -0,0 +1,18 @@
1
+ """Models for FinCLI research workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from fincli.app.services.market_overview import MarketOverview
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class ResearchBrief:
12
+ symbol: str
13
+ mode: str
14
+ overview: MarketOverview
15
+ decision_points: list[str]
16
+ risks: list[str]
17
+ final_summary: str
18
+ ai_summary: str = ""
@@ -0,0 +1,47 @@
1
+ """Prompt builder for deep research mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fincli.app.research.models import ResearchBrief
6
+
7
+
8
+ RESEARCH_WORKSPACE_PROMPT = """
9
+ You are FinCLI Research Workspace.
10
+
11
+ Rules:
12
+ - Build a concise investment/trading research note from the provided data only.
13
+ - Do not invent price, news, fundamentals, or certainty.
14
+ - Do not copy the opening summary as the final summary.
15
+ - Prioritize decision-useful points over long explanation.
16
+ - Separate facts, interpretation, and risk.
17
+ - Keep output short: max 8 bullets plus final summary.
18
+ - Use slash-command context correctly: FinCLI commands start with "/", not "fincli".
19
+ - This is educational market research, not financial advice.
20
+ """.strip()
21
+
22
+
23
+ def build_research_prompt(brief: ResearchBrief) -> str:
24
+ overview = brief.overview
25
+ news = "\n".join(f"- {item.title} ({item.source}) {item.summary}" for item in overview.news) or "- No news."
26
+ fundamentals = overview.fundamentals
27
+ fundamentals_text = (
28
+ "No fundamentals."
29
+ if fundamentals is None
30
+ else (
31
+ f"market_cap={fundamentals.market_cap}; pe={fundamentals.pe_ratio}; eps={fundamentals.eps}; "
32
+ f"revenue={fundamentals.revenue}; sector={fundamentals.sector}; industry={fundamentals.industry}"
33
+ )
34
+ )
35
+ return (
36
+ f"{RESEARCH_WORKSPACE_PROMPT}\n\n"
37
+ f"Symbol: {brief.symbol}\n"
38
+ f"Mode: {brief.mode}\n"
39
+ f"Quote: {overview.quote.price} {overview.quote.currency} via {overview.quote.provider} ({overview.quote.status})\n"
40
+ f"Data Quality: {overview.data_quality.score}/100; OHLCV={overview.data_quality.ohlcv}; News={overview.data_quality.news}; Fundamentals={overview.data_quality.fundamentals}\n"
41
+ f"Technical: trend={overview.technical.trend_bias}; rsi={overview.technical.rsi}; macd={overview.technical.macd}; support={overview.technical.support}; resistance={overview.technical.resistance}; atr={overview.technical.atr}\n"
42
+ f"Structure: trend={overview.structure.trend}; pattern={overview.structure.latest_pattern}; bos={overview.structure.break_of_structure}; choch={overview.structure.change_of_character}\n"
43
+ f"Decision Points:\n{chr(10).join(f'- {point}' for point in brief.decision_points)}\n"
44
+ f"Risks:\n{chr(10).join(f'- {risk}' for risk in brief.risks)}\n"
45
+ f"News:\n{news}\n"
46
+ f"Fundamentals: {fundamentals_text}\n"
47
+ )
@@ -0,0 +1,50 @@
1
+ """Macro data service with offline-first fallback rows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class MacroIndicator:
11
+ name: str
12
+ region: str
13
+ value: str
14
+ period: str
15
+ source: str
16
+ note: str
17
+
18
+
19
+ class MacroDataService:
20
+ """Return macro context from free fallback datasets.
21
+
22
+ v0.2.2 keeps this deterministic/offline so /macro remains usable without API keys.
23
+ Provider-backed DBnomics/FRED/World Bank adapters can hydrate this shape later.
24
+ """
25
+
26
+ def indicators(self, query: str = "") -> list[MacroIndicator]:
27
+ normalized = query.strip().lower()
28
+ rows = _fallback_rows()
29
+ if not normalized:
30
+ return rows
31
+ filtered = [
32
+ row
33
+ for row in rows
34
+ if normalized in row.region.lower()
35
+ or normalized in row.name.lower()
36
+ or normalized in row.note.lower()
37
+ ]
38
+ return filtered or rows[:5]
39
+
40
+
41
+ def _fallback_rows() -> list[MacroIndicator]:
42
+ period = date.today().strftime("%Y")
43
+ return [
44
+ MacroIndicator("Policy Rate", "United States", "provider required", period, "Fallback", "Watch FRED/Fed data for rate path."),
45
+ MacroIndicator("Inflation", "United States", "provider required", period, "Fallback", "CPI trend drives USD, yields, and risk assets."),
46
+ MacroIndicator("GDP Growth", "World", "provider required", period, "Fallback", "Use World Bank/IMF for actual country values."),
47
+ MacroIndicator("Policy Rate", "Indonesia", "provider required", period, "Fallback", "BI rate, USD strength, and capital flow affect IDR."),
48
+ MacroIndicator("Inflation", "Indonesia", "provider required", period, "Fallback", "Inflation surprise can affect BI policy expectations."),
49
+ MacroIndicator("PMI", "Euro Area", "provider required", period, "Fallback", "Growth momentum proxy for EUR and European equities."),
50
+ ]