@drico2008/fincli 0.1.9 → 0.3.0
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.
- package/LICENSE +21 -0
- package/README.md +124 -625
- package/fincli/__init__.py +3 -3
- package/fincli/app/agents/__init__.py +5 -0
- package/fincli/app/agents/registry.py +76 -0
- package/fincli/app/analysis/ai_prompts.py +26 -14
- package/fincli/app/analysis/analyzer.py +107 -96
- package/fincli/app/analysis/assistant_context.py +187 -186
- package/fincli/app/analysis/backtest.py +179 -0
- package/fincli/app/analysis/gameplay_plan.py +79 -0
- package/fincli/app/analysis/multi_timeframe.py +180 -0
- package/fincli/app/analysis/trading_methods.py +144 -0
- package/fincli/app/cli/commands.py +108 -81
- package/fincli/app/cli/router.py +2327 -1237
- package/fincli/app/connectors/__init__.py +5 -0
- package/fincli/app/connectors/catalog.py +148 -0
- package/fincli/app/connectors/news_connectors.py +412 -0
- package/fincli/app/modules/alerts.py +80 -0
- package/fincli/app/modules/economic_calendar.py +374 -1
- package/fincli/app/modules/portfolio_risk.py +305 -0
- package/fincli/app/modules/reports.py +151 -0
- package/fincli/app/modules/scanner.py +111 -93
- package/fincli/app/modules/transactions.py +84 -84
- package/fincli/app/modules/user_profile.py +84 -0
- package/fincli/app/plugins/loader.py +72 -0
- package/fincli/app/providers/ai/manager.py +60 -60
- package/fincli/app/providers/market/alphavantage_provider.py +194 -0
- package/fincli/app/providers/market/base.py +98 -77
- package/fincli/app/providers/market/custom_provider.py +186 -169
- package/fincli/app/providers/market/manager.py +84 -1
- package/fincli/app/providers/market/symbols.py +143 -0
- package/fincli/app/providers/market/twelvedata_provider.py +167 -167
- package/fincli/app/providers/reliability.py +86 -0
- package/fincli/app/research/__init__.py +8 -0
- package/fincli/app/research/engine.py +137 -0
- package/fincli/app/research/exporter.py +91 -0
- package/fincli/app/research/formatter.py +27 -0
- package/fincli/app/research/models.py +24 -0
- package/fincli/app/research/prompt_builder.py +54 -0
- package/fincli/app/services/macro_data.py +50 -0
- package/fincli/app/services/market_data.py +274 -169
- package/fincli/app/services/market_overview.py +42 -1
- package/fincli/app/services/news_aggregator.py +95 -0
- package/fincli/app/services/web_research.py +267 -267
- package/fincli/app/storage/config.py +122 -88
- package/fincli/app/storage/database.py +209 -99
- package/fincli/app/storage/provider_metrics.py +61 -0
- package/fincli/app/storage/secrets.py +26 -2
- package/fincli/app/tui/components.py +68 -50
- package/fincli/app/tui/layout.py +269 -258
- package/fincli/app/tui/market_provider_selector.py +3 -1
- package/fincli/app/tui/theme.py +134 -74
- package/fincli/app/utils/formatting.py +123 -60
- package/package.json +22 -20
- package/pyproject.toml +35 -35
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Portfolio Risk v2 analytics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from fincli.app.modules.user_profile import UserProfile
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class AssetClassExposure:
|
|
12
|
+
asset_class: str
|
|
13
|
+
market_value: float
|
|
14
|
+
weight: float
|
|
15
|
+
count: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class CurrencyExposure:
|
|
20
|
+
currency: str
|
|
21
|
+
market_value: float
|
|
22
|
+
weight: float
|
|
23
|
+
count: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ConcentrationRisk:
|
|
28
|
+
top_symbol: str
|
|
29
|
+
top_weight: float
|
|
30
|
+
level: str
|
|
31
|
+
note: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class PortfolioHealth:
|
|
36
|
+
score: int
|
|
37
|
+
label: str
|
|
38
|
+
notes: tuple[str, ...]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class AssetClassWarning:
|
|
43
|
+
asset_class: str
|
|
44
|
+
weight: float
|
|
45
|
+
cap: float
|
|
46
|
+
level: str
|
|
47
|
+
note: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class RiskBudget:
|
|
52
|
+
profile_gameplay: str
|
|
53
|
+
equity: float
|
|
54
|
+
currency: str
|
|
55
|
+
risk_per_trade: float
|
|
56
|
+
max_portfolio_risk: float
|
|
57
|
+
note: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class PortfolioRiskReport:
|
|
62
|
+
total_cost_basis: float
|
|
63
|
+
total_market_value: float
|
|
64
|
+
realized_pnl: float
|
|
65
|
+
unrealized_pnl: float
|
|
66
|
+
total_pnl: float
|
|
67
|
+
exposure_by_asset_class: dict[str, AssetClassExposure]
|
|
68
|
+
currency_exposure: dict[str, CurrencyExposure]
|
|
69
|
+
concentration: ConcentrationRisk
|
|
70
|
+
health: PortfolioHealth
|
|
71
|
+
drawdown_estimate: float
|
|
72
|
+
asset_class_warnings: tuple[AssetClassWarning, ...]
|
|
73
|
+
risk_budget: RiskBudget
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_portfolio_risk(
|
|
77
|
+
positions: list[dict[str, object]],
|
|
78
|
+
market_values: dict[str, tuple[float | None, float | None, float | None]],
|
|
79
|
+
realized_pnl: float,
|
|
80
|
+
profile: UserProfile | None = None,
|
|
81
|
+
) -> PortfolioRiskReport:
|
|
82
|
+
"""Build exposure, concentration, PnL, and health score from positions."""
|
|
83
|
+
total_cost_basis = 0.0
|
|
84
|
+
total_market_value = 0.0
|
|
85
|
+
unrealized_pnl = 0.0
|
|
86
|
+
exposure_values: dict[str, float] = {}
|
|
87
|
+
exposure_counts: dict[str, int] = {}
|
|
88
|
+
currency_values: dict[str, float] = {}
|
|
89
|
+
currency_counts: dict[str, int] = {}
|
|
90
|
+
symbol_values: dict[str, float] = {}
|
|
91
|
+
missing_prices = 0
|
|
92
|
+
|
|
93
|
+
for row in positions:
|
|
94
|
+
symbol = str(row["symbol"]).upper()
|
|
95
|
+
quantity = float(row["quantity"])
|
|
96
|
+
average_price = float(row["average_price"])
|
|
97
|
+
cost_basis = quantity * average_price
|
|
98
|
+
total_cost_basis += cost_basis
|
|
99
|
+
|
|
100
|
+
current_price, pnl, _pnl_percent = market_values.get(symbol, (None, None, None))
|
|
101
|
+
if current_price is None:
|
|
102
|
+
missing_prices += 1
|
|
103
|
+
market_value = cost_basis
|
|
104
|
+
else:
|
|
105
|
+
market_value = quantity * current_price
|
|
106
|
+
total_market_value += market_value
|
|
107
|
+
if pnl is not None:
|
|
108
|
+
unrealized_pnl += pnl
|
|
109
|
+
|
|
110
|
+
asset_class = classify_asset_class(symbol)
|
|
111
|
+
exposure_values[asset_class] = exposure_values.get(asset_class, 0.0) + market_value
|
|
112
|
+
exposure_counts[asset_class] = exposure_counts.get(asset_class, 0) + 1
|
|
113
|
+
currency = str(row.get("currency", "USD")).upper()
|
|
114
|
+
currency_values[currency] = currency_values.get(currency, 0.0) + market_value
|
|
115
|
+
currency_counts[currency] = currency_counts.get(currency, 0) + 1
|
|
116
|
+
symbol_values[symbol] = market_value
|
|
117
|
+
|
|
118
|
+
exposure_by_asset_class = {
|
|
119
|
+
asset_class: AssetClassExposure(
|
|
120
|
+
asset_class=asset_class,
|
|
121
|
+
market_value=value,
|
|
122
|
+
weight=_weight(value, total_market_value),
|
|
123
|
+
count=exposure_counts.get(asset_class, 0),
|
|
124
|
+
)
|
|
125
|
+
for asset_class, value in sorted(exposure_values.items())
|
|
126
|
+
}
|
|
127
|
+
currency_exposure = {
|
|
128
|
+
currency: CurrencyExposure(
|
|
129
|
+
currency=currency,
|
|
130
|
+
market_value=value,
|
|
131
|
+
weight=_weight(value, total_market_value),
|
|
132
|
+
count=currency_counts.get(currency, 0),
|
|
133
|
+
)
|
|
134
|
+
for currency, value in sorted(currency_values.items())
|
|
135
|
+
}
|
|
136
|
+
concentration = _concentration(symbol_values, total_market_value)
|
|
137
|
+
total_pnl = realized_pnl + unrealized_pnl
|
|
138
|
+
drawdown_estimate = _drawdown_estimate(unrealized_pnl, total_cost_basis)
|
|
139
|
+
warnings = _asset_class_warnings(exposure_by_asset_class)
|
|
140
|
+
risk_budget = _risk_budget(profile)
|
|
141
|
+
health = _health_score(
|
|
142
|
+
positions_count=len(positions),
|
|
143
|
+
top_weight=concentration.top_weight,
|
|
144
|
+
missing_prices=missing_prices,
|
|
145
|
+
total_pnl=total_pnl,
|
|
146
|
+
total_cost_basis=total_cost_basis,
|
|
147
|
+
asset_class_count=len(exposure_by_asset_class),
|
|
148
|
+
drawdown_estimate=drawdown_estimate,
|
|
149
|
+
warning_count=len(warnings),
|
|
150
|
+
)
|
|
151
|
+
return PortfolioRiskReport(
|
|
152
|
+
total_cost_basis=total_cost_basis,
|
|
153
|
+
total_market_value=total_market_value,
|
|
154
|
+
realized_pnl=realized_pnl,
|
|
155
|
+
unrealized_pnl=unrealized_pnl,
|
|
156
|
+
total_pnl=total_pnl,
|
|
157
|
+
exposure_by_asset_class=exposure_by_asset_class,
|
|
158
|
+
currency_exposure=currency_exposure,
|
|
159
|
+
concentration=concentration,
|
|
160
|
+
health=health,
|
|
161
|
+
drawdown_estimate=drawdown_estimate,
|
|
162
|
+
asset_class_warnings=warnings,
|
|
163
|
+
risk_budget=risk_budget,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def classify_asset_class(symbol: str) -> str:
|
|
168
|
+
normalized = symbol.upper()
|
|
169
|
+
if normalized.endswith("-USD") or normalized.endswith("USDT") or normalized in {"BTC", "ETH", "SOL", "BNB"}:
|
|
170
|
+
return "crypto"
|
|
171
|
+
if normalized.endswith("=X") or len(normalized) == 6 and normalized.isalpha():
|
|
172
|
+
return "forex"
|
|
173
|
+
if normalized.startswith("^") or normalized in {"SPX", "NASDAQ", "DJI", "DAX", "NIKKEI", "HSI"}:
|
|
174
|
+
return "index"
|
|
175
|
+
if normalized.endswith("=F") or normalized in {"XAUUSD", "XAGUSD", "WTI", "BRENT", "GOLD", "SILVER"}:
|
|
176
|
+
return "commodity"
|
|
177
|
+
if normalized.endswith(".JK") or normalized.endswith(".L") or normalized.endswith(".TO") or normalized.isalpha():
|
|
178
|
+
return "equity"
|
|
179
|
+
return "other"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _concentration(symbol_values: dict[str, float], total_market_value: float) -> ConcentrationRisk:
|
|
183
|
+
if not symbol_values or total_market_value <= 0:
|
|
184
|
+
return ConcentrationRisk("-", 0.0, "empty", "No market value available.")
|
|
185
|
+
top_symbol, top_value = max(symbol_values.items(), key=lambda item: item[1])
|
|
186
|
+
top_weight = _weight(top_value, total_market_value)
|
|
187
|
+
if top_weight >= 60:
|
|
188
|
+
level = "high"
|
|
189
|
+
note = "Top position dominates portfolio."
|
|
190
|
+
elif top_weight >= 35:
|
|
191
|
+
level = "medium"
|
|
192
|
+
note = "Top position needs monitoring."
|
|
193
|
+
else:
|
|
194
|
+
level = "healthy"
|
|
195
|
+
note = "No single position dominates."
|
|
196
|
+
return ConcentrationRisk(top_symbol, top_weight, level, note)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _health_score(
|
|
200
|
+
positions_count: int,
|
|
201
|
+
top_weight: float,
|
|
202
|
+
missing_prices: int,
|
|
203
|
+
total_pnl: float,
|
|
204
|
+
total_cost_basis: float,
|
|
205
|
+
asset_class_count: int,
|
|
206
|
+
drawdown_estimate: float,
|
|
207
|
+
warning_count: int,
|
|
208
|
+
) -> PortfolioHealth:
|
|
209
|
+
score = 100
|
|
210
|
+
notes: list[str] = []
|
|
211
|
+
if positions_count == 0:
|
|
212
|
+
return PortfolioHealth(0, "empty", ("No positions.",))
|
|
213
|
+
if positions_count < 3:
|
|
214
|
+
score -= 15
|
|
215
|
+
notes.append("few positions")
|
|
216
|
+
if asset_class_count < 2:
|
|
217
|
+
score -= 10
|
|
218
|
+
notes.append("single asset class")
|
|
219
|
+
if top_weight >= 60:
|
|
220
|
+
score -= 25
|
|
221
|
+
notes.append("high concentration")
|
|
222
|
+
elif top_weight >= 35:
|
|
223
|
+
score -= 10
|
|
224
|
+
notes.append("medium concentration")
|
|
225
|
+
if missing_prices:
|
|
226
|
+
score -= min(30, missing_prices * 10)
|
|
227
|
+
notes.append(f"{missing_prices} missing price(s)")
|
|
228
|
+
if warning_count:
|
|
229
|
+
score -= min(20, warning_count * 8)
|
|
230
|
+
notes.append(f"{warning_count} asset-class cap warning(s)")
|
|
231
|
+
if drawdown_estimate <= -25:
|
|
232
|
+
score -= 15
|
|
233
|
+
notes.append("deep drawdown estimate")
|
|
234
|
+
pnl_ratio = (total_pnl / total_cost_basis * 100) if total_cost_basis else 0.0
|
|
235
|
+
if pnl_ratio <= -20:
|
|
236
|
+
score -= 20
|
|
237
|
+
notes.append("large drawdown")
|
|
238
|
+
elif pnl_ratio < 0:
|
|
239
|
+
score -= 8
|
|
240
|
+
notes.append("negative total PnL")
|
|
241
|
+
|
|
242
|
+
score = max(0, min(100, score))
|
|
243
|
+
if score >= 80:
|
|
244
|
+
label = "healthy"
|
|
245
|
+
elif score >= 60:
|
|
246
|
+
label = "watch"
|
|
247
|
+
elif score >= 40:
|
|
248
|
+
label = "caution"
|
|
249
|
+
else:
|
|
250
|
+
label = "high risk"
|
|
251
|
+
return PortfolioHealth(score, label, tuple(notes) or ("balanced baseline",))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _weight(value: float, total: float) -> float:
|
|
255
|
+
return (value / total * 100) if total else 0.0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _drawdown_estimate(unrealized_pnl: float, total_cost_basis: float) -> float:
|
|
259
|
+
if total_cost_basis <= 0:
|
|
260
|
+
return 0.0
|
|
261
|
+
return min(0.0, unrealized_pnl / total_cost_basis * 100)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _asset_class_warnings(exposures: dict[str, AssetClassExposure]) -> tuple[AssetClassWarning, ...]:
|
|
265
|
+
caps = {"crypto": 25.0, "forex": 40.0, "commodity": 35.0, "index": 50.0, "equity": 70.0, "other": 25.0}
|
|
266
|
+
warnings: list[AssetClassWarning] = []
|
|
267
|
+
for exposure in exposures.values():
|
|
268
|
+
cap = caps.get(exposure.asset_class, 25.0)
|
|
269
|
+
if exposure.weight > cap:
|
|
270
|
+
warnings.append(
|
|
271
|
+
AssetClassWarning(
|
|
272
|
+
asset_class=exposure.asset_class,
|
|
273
|
+
weight=exposure.weight,
|
|
274
|
+
cap=cap,
|
|
275
|
+
level="high" if exposure.weight >= cap + 20 else "watch",
|
|
276
|
+
note=f"{exposure.asset_class} exposure {exposure.weight:.2f}% exceeds cap {cap:.2f}%.",
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
return tuple(warnings)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _risk_budget(profile: UserProfile | None) -> RiskBudget:
|
|
283
|
+
if profile is None:
|
|
284
|
+
return RiskBudget("unprofiled", 0.0, "USD", 0.0, 0.0, "Run /profile set to enable risk budget.")
|
|
285
|
+
gameplay = profile.gameplay
|
|
286
|
+
if gameplay == "Scalper":
|
|
287
|
+
per_trade_pct = 1.0
|
|
288
|
+
max_portfolio_pct = 5.0
|
|
289
|
+
elif gameplay == "Intra day":
|
|
290
|
+
per_trade_pct = 1.25
|
|
291
|
+
max_portfolio_pct = 7.5
|
|
292
|
+
elif gameplay == "Day trade":
|
|
293
|
+
per_trade_pct = 1.5
|
|
294
|
+
max_portfolio_pct = 10.0
|
|
295
|
+
else:
|
|
296
|
+
per_trade_pct = 2.0
|
|
297
|
+
max_portfolio_pct = 12.0
|
|
298
|
+
return RiskBudget(
|
|
299
|
+
profile_gameplay=gameplay,
|
|
300
|
+
equity=profile.equity,
|
|
301
|
+
currency=profile.currency,
|
|
302
|
+
risk_per_trade=profile.equity * per_trade_pct / 100,
|
|
303
|
+
max_portfolio_risk=profile.equity * max_portfolio_pct / 100,
|
|
304
|
+
note=f"{per_trade_pct:.2f}% per trade, {max_portfolio_pct:.2f}% max portfolio risk budget.",
|
|
305
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Exportable market report helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fincli.app.services.market_overview import MarketOverview
|
|
10
|
+
from fincli.app.utils.errors import CommandError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def write_market_report(overview: MarketOverview, fmt: str, target: str | Path) -> Path:
|
|
14
|
+
report_format = fmt.lower()
|
|
15
|
+
path = _safe_report_path(target, report_format)
|
|
16
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
if report_format == "json":
|
|
18
|
+
path.write_text(json.dumps(_overview_payload(overview), indent=2, default=str), encoding="utf-8")
|
|
19
|
+
return path
|
|
20
|
+
if report_format in {"md", "markdown"}:
|
|
21
|
+
path.write_text(_overview_markdown(overview), encoding="utf-8")
|
|
22
|
+
return path
|
|
23
|
+
raise CommandError("Report format must be md or json.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _safe_report_path(target: str | Path, fmt: str) -> Path:
|
|
27
|
+
path = Path(target).expanduser()
|
|
28
|
+
if any(part == ".." for part in path.parts):
|
|
29
|
+
raise CommandError("Report path must not contain '..'.")
|
|
30
|
+
allowed = {".md", ".json"} if fmt in {"md", "markdown", "json"} else set()
|
|
31
|
+
if path.suffix.lower() not in allowed:
|
|
32
|
+
raise CommandError("Report path must end with .md or .json.")
|
|
33
|
+
return path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _overview_payload(overview: MarketOverview) -> dict[str, Any]:
|
|
37
|
+
return {
|
|
38
|
+
"symbol": overview.symbol,
|
|
39
|
+
"timeframe": overview.timeframe,
|
|
40
|
+
"quote": {
|
|
41
|
+
"symbol": overview.quote.symbol,
|
|
42
|
+
"price": overview.quote.price,
|
|
43
|
+
"currency": overview.quote.currency,
|
|
44
|
+
"provider": overview.quote.provider,
|
|
45
|
+
"status": overview.quote.status,
|
|
46
|
+
"timestamp": overview.quote.timestamp.isoformat(),
|
|
47
|
+
},
|
|
48
|
+
"data_quality": {
|
|
49
|
+
"score": overview.data_quality.score,
|
|
50
|
+
"quote": overview.data_quality.quote,
|
|
51
|
+
"ohlcv": overview.data_quality.ohlcv,
|
|
52
|
+
"news": overview.data_quality.news,
|
|
53
|
+
"fundamentals": overview.data_quality.fundamentals,
|
|
54
|
+
"provider": overview.data_quality.provider,
|
|
55
|
+
},
|
|
56
|
+
"technical": {
|
|
57
|
+
"latest_close": overview.technical.latest_close,
|
|
58
|
+
"trend_bias": overview.technical.trend_bias,
|
|
59
|
+
"rsi": overview.technical.rsi,
|
|
60
|
+
"macd": overview.technical.macd,
|
|
61
|
+
"macd_signal": overview.technical.macd_signal,
|
|
62
|
+
"atr": overview.technical.atr,
|
|
63
|
+
"support": overview.technical.support,
|
|
64
|
+
"resistance": overview.technical.resistance,
|
|
65
|
+
},
|
|
66
|
+
"structure": {
|
|
67
|
+
"trend": overview.structure.trend,
|
|
68
|
+
"latest_pattern": overview.structure.latest_pattern,
|
|
69
|
+
"break_of_structure": overview.structure.break_of_structure,
|
|
70
|
+
"change_of_character": overview.structure.change_of_character,
|
|
71
|
+
"liquidity_area": overview.structure.liquidity_area,
|
|
72
|
+
"risk_zone": overview.structure.risk_zone,
|
|
73
|
+
},
|
|
74
|
+
"fundamentals": None
|
|
75
|
+
if overview.fundamentals is None
|
|
76
|
+
else {
|
|
77
|
+
"provider": overview.fundamentals.provider,
|
|
78
|
+
"currency": overview.fundamentals.currency,
|
|
79
|
+
"market_cap": overview.fundamentals.market_cap,
|
|
80
|
+
"pe_ratio": overview.fundamentals.pe_ratio,
|
|
81
|
+
"eps": overview.fundamentals.eps,
|
|
82
|
+
"revenue": overview.fundamentals.revenue,
|
|
83
|
+
"sector": overview.fundamentals.sector,
|
|
84
|
+
"industry": overview.fundamentals.industry,
|
|
85
|
+
},
|
|
86
|
+
"news": [
|
|
87
|
+
{
|
|
88
|
+
"title": item.title,
|
|
89
|
+
"source": item.source,
|
|
90
|
+
"url": item.url,
|
|
91
|
+
"published_at": item.published_at.isoformat() if item.published_at else None,
|
|
92
|
+
"summary": item.summary,
|
|
93
|
+
}
|
|
94
|
+
for item in overview.news
|
|
95
|
+
],
|
|
96
|
+
"disclaimer": "Informational only. Not financial advice.",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _overview_markdown(overview: MarketOverview) -> str:
|
|
101
|
+
payload = _overview_payload(overview)
|
|
102
|
+
news_lines = "\n".join(
|
|
103
|
+
f"- {item['title']} ({item['source']})" + (f" - {item['url']}" if item["url"] else "")
|
|
104
|
+
for item in payload["news"]
|
|
105
|
+
)
|
|
106
|
+
fundamentals = payload["fundamentals"] or {}
|
|
107
|
+
return "\n".join(
|
|
108
|
+
[
|
|
109
|
+
f"# FinCLI Market Report: {overview.symbol}",
|
|
110
|
+
"",
|
|
111
|
+
f"- Timeframe: {overview.timeframe}",
|
|
112
|
+
f"- Provider: {overview.quote.provider} ({overview.quote.status})",
|
|
113
|
+
f"- Data Quality: {overview.data_quality.score}/100",
|
|
114
|
+
"",
|
|
115
|
+
"## Quote",
|
|
116
|
+
"",
|
|
117
|
+
f"- Price: {overview.quote.price} {overview.quote.currency}",
|
|
118
|
+
f"- Timestamp: {overview.quote.timestamp.isoformat(timespec='seconds')}",
|
|
119
|
+
"",
|
|
120
|
+
"## Technical",
|
|
121
|
+
"",
|
|
122
|
+
f"- Trend Bias: {overview.technical.trend_bias}",
|
|
123
|
+
f"- RSI: {overview.technical.rsi}",
|
|
124
|
+
f"- MACD: {overview.technical.macd} / {overview.technical.macd_signal}",
|
|
125
|
+
f"- ATR: {overview.technical.atr}",
|
|
126
|
+
f"- Support / Resistance: {overview.technical.support} / {overview.technical.resistance}",
|
|
127
|
+
"",
|
|
128
|
+
"## Market Structure",
|
|
129
|
+
"",
|
|
130
|
+
f"- Trend: {overview.structure.trend}",
|
|
131
|
+
f"- Pattern: {overview.structure.latest_pattern}",
|
|
132
|
+
f"- BOS / CHoCH: {overview.structure.break_of_structure} / {overview.structure.change_of_character}",
|
|
133
|
+
f"- Liquidity Area: {overview.structure.liquidity_area}",
|
|
134
|
+
f"- Risk Zone: {overview.structure.risk_zone}",
|
|
135
|
+
"",
|
|
136
|
+
"## Fundamentals",
|
|
137
|
+
"",
|
|
138
|
+
f"- Sector / Industry: {fundamentals.get('sector', 'N/A')} / {fundamentals.get('industry', 'N/A')}",
|
|
139
|
+
f"- Market Cap: {fundamentals.get('market_cap', 'N/A')}",
|
|
140
|
+
f"- P/E / EPS: {fundamentals.get('pe_ratio', 'N/A')} / {fundamentals.get('eps', 'N/A')}",
|
|
141
|
+
"",
|
|
142
|
+
"## Latest News",
|
|
143
|
+
"",
|
|
144
|
+
news_lines or "- No recent news returned by provider.",
|
|
145
|
+
"",
|
|
146
|
+
"## Disclaimer",
|
|
147
|
+
"",
|
|
148
|
+
"Informational only. Not financial advice.",
|
|
149
|
+
"",
|
|
150
|
+
]
|
|
151
|
+
)
|
|
@@ -1,105 +1,123 @@
|
|
|
1
|
-
"""Watchlist scanner with simple technical filters."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
1
|
+
"""Watchlist scanner with simple technical filters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
5
|
import asyncio
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
+
import re
|
|
7
8
|
|
|
8
9
|
from fincli.app.analysis.indicators import TechnicalSummary, summarize_technical_indicators
|
|
9
10
|
from fincli.app.providers.market.base import BaseMarketProvider
|
|
11
|
+
from fincli.app.utils.errors import CommandError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ScanResult:
|
|
16
|
+
symbol: str
|
|
17
|
+
latest_close: float
|
|
18
|
+
rsi: float | None
|
|
19
|
+
trend_bias: str
|
|
20
|
+
support: float | None
|
|
21
|
+
resistance: float | None
|
|
22
|
+
matched: bool
|
|
23
|
+
reason: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def scan_symbols(
|
|
27
|
+
symbols: list[str],
|
|
28
|
+
provider: BaseMarketProvider,
|
|
29
|
+
filter_expression: str = "",
|
|
30
|
+
interval: str = "1d",
|
|
31
|
+
batch_size: int = 25,
|
|
32
|
+
) -> list[ScanResult]:
|
|
33
|
+
"""Scan symbols in bounded async batches."""
|
|
34
|
+
results: list[ScanResult] = []
|
|
35
|
+
for index in range(0, len(symbols), batch_size):
|
|
36
|
+
batch = symbols[index : index + batch_size]
|
|
37
|
+
scanned = await asyncio.gather(
|
|
38
|
+
*[_scan_symbol(symbol, provider, filter_expression, interval) for symbol in batch],
|
|
39
|
+
return_exceptions=True,
|
|
40
|
+
)
|
|
41
|
+
for item in scanned:
|
|
42
|
+
if isinstance(item, ScanResult) and item.matched:
|
|
43
|
+
results.append(item)
|
|
44
|
+
return results
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _scan_symbol(
|
|
48
|
+
symbol: str,
|
|
49
|
+
provider: BaseMarketProvider,
|
|
50
|
+
filter_expression: str,
|
|
51
|
+
interval: str,
|
|
52
|
+
) -> ScanResult:
|
|
53
|
+
candles = await provider.history(symbol, period="6mo", interval=interval)
|
|
54
|
+
summary = summarize_technical_indicators(candles)
|
|
55
|
+
matched, reason = _matches_filter(summary, filter_expression)
|
|
56
|
+
return ScanResult(
|
|
57
|
+
symbol=symbol.upper(),
|
|
58
|
+
latest_close=summary.latest_close,
|
|
59
|
+
rsi=summary.rsi,
|
|
60
|
+
trend_bias=summary.trend_bias,
|
|
61
|
+
support=summary.support,
|
|
62
|
+
resistance=summary.resistance,
|
|
63
|
+
matched=matched,
|
|
64
|
+
reason=reason,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _matches_filter(summary: TechnicalSummary, expression: str) -> tuple[bool, str]:
|
|
69
|
+
return matches_filter_expression(summary, expression)
|
|
10
70
|
|
|
11
71
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
symbol: str
|
|
15
|
-
latest_close: float
|
|
16
|
-
rsi: float | None
|
|
17
|
-
trend_bias: str
|
|
18
|
-
support: float | None
|
|
19
|
-
resistance: float | None
|
|
20
|
-
matched: bool
|
|
21
|
-
reason: str
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
async def scan_symbols(
|
|
25
|
-
symbols: list[str],
|
|
26
|
-
provider: BaseMarketProvider,
|
|
27
|
-
filter_expression: str = "",
|
|
28
|
-
interval: str = "1d",
|
|
29
|
-
batch_size: int = 25,
|
|
30
|
-
) -> list[ScanResult]:
|
|
31
|
-
"""Scan symbols in bounded async batches."""
|
|
32
|
-
results: list[ScanResult] = []
|
|
33
|
-
for index in range(0, len(symbols), batch_size):
|
|
34
|
-
batch = symbols[index : index + batch_size]
|
|
35
|
-
scanned = await asyncio.gather(
|
|
36
|
-
*[_scan_symbol(symbol, provider, filter_expression, interval) for symbol in batch],
|
|
37
|
-
return_exceptions=True,
|
|
38
|
-
)
|
|
39
|
-
for item in scanned:
|
|
40
|
-
if isinstance(item, ScanResult) and item.matched:
|
|
41
|
-
results.append(item)
|
|
42
|
-
return results
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
async def _scan_symbol(
|
|
46
|
-
symbol: str,
|
|
47
|
-
provider: BaseMarketProvider,
|
|
48
|
-
filter_expression: str,
|
|
49
|
-
interval: str,
|
|
50
|
-
) -> ScanResult:
|
|
51
|
-
candles = await provider.history(symbol, period="6mo", interval=interval)
|
|
52
|
-
summary = summarize_technical_indicators(candles)
|
|
53
|
-
matched, reason = _matches_filter(summary, filter_expression)
|
|
54
|
-
return ScanResult(
|
|
55
|
-
symbol=symbol.upper(),
|
|
56
|
-
latest_close=summary.latest_close,
|
|
57
|
-
rsi=summary.rsi,
|
|
58
|
-
trend_bias=summary.trend_bias,
|
|
59
|
-
support=summary.support,
|
|
60
|
-
resistance=summary.resistance,
|
|
61
|
-
matched=matched,
|
|
62
|
-
reason=reason,
|
|
63
|
-
)
|
|
64
|
-
|
|
72
|
+
def matches_filter_expression(summary: TechnicalSummary, expression: str) -> tuple[bool, str]:
|
|
73
|
+
"""Evaluate a small, explicit scan expression language.
|
|
65
74
|
|
|
66
|
-
|
|
75
|
+
Supported terms: trend=<bias>, rsi<number, rsi>number.
|
|
76
|
+
Supported operators: and, or. Comma is treated as and.
|
|
77
|
+
"""
|
|
67
78
|
expr = expression.strip().lower()
|
|
68
79
|
if not expr:
|
|
69
80
|
return True, "all"
|
|
70
81
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if expr.startswith("
|
|
85
|
-
|
|
86
|
-
return summary.
|
|
87
|
-
|
|
88
|
-
if expr.startswith("rsi
|
|
89
|
-
threshold = _parse_threshold(expr, "rsi
|
|
90
|
-
return summary.rsi is not None and summary.rsi
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def
|
|
103
|
-
|
|
104
|
-
return "
|
|
105
|
-
|
|
82
|
+
normalized = expr.replace(",", " and ")
|
|
83
|
+
or_groups = [group.strip() for group in re.split(r"\s+or\s+", normalized) if group.strip()]
|
|
84
|
+
group_results: list[tuple[bool, str]] = []
|
|
85
|
+
for group in or_groups:
|
|
86
|
+
terms = [term.strip() for term in re.split(r"\s+and\s+|\s+", group) if term.strip()]
|
|
87
|
+
evaluations = [_matches_single_filter(summary, term) for term in terms]
|
|
88
|
+
group_results.append((all(item[0] for item in evaluations), "; ".join(item[1] for item in evaluations)))
|
|
89
|
+
|
|
90
|
+
matched = any(item[0] for item in group_results)
|
|
91
|
+
return matched, " OR ".join(item[1] for item in group_results)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _matches_single_filter(summary: TechnicalSummary, expr: str) -> tuple[bool, str]:
|
|
95
|
+
if expr.startswith("trend="):
|
|
96
|
+
expected = expr.split("=", 1)[1].strip()
|
|
97
|
+
return summary.trend_bias == expected, f"trend={summary.trend_bias}"
|
|
98
|
+
|
|
99
|
+
if expr.startswith("rsi<"):
|
|
100
|
+
threshold = _parse_threshold(expr, "rsi<")
|
|
101
|
+
return summary.rsi is not None and summary.rsi < threshold, f"rsi={_fmt(summary.rsi)} < {threshold:g}"
|
|
102
|
+
|
|
103
|
+
if expr.startswith("rsi>"):
|
|
104
|
+
threshold = _parse_threshold(expr, "rsi>")
|
|
105
|
+
return summary.rsi is not None and summary.rsi > threshold, f"rsi={_fmt(summary.rsi)} > {threshold:g}"
|
|
106
|
+
|
|
107
|
+
raise CommandError(
|
|
108
|
+
f"Filter scan tidak dikenal: {expr}",
|
|
109
|
+
"Gunakan filter seperti trend=bullish, rsi<30, rsi>70, atau gabungkan dengan and/or.",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_threshold(expression: str, prefix: str) -> float:
|
|
114
|
+
try:
|
|
115
|
+
return float(expression.replace(prefix, "", 1).strip())
|
|
116
|
+
except ValueError:
|
|
117
|
+
return 0.0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _fmt(value: float | None) -> str:
|
|
121
|
+
if value is None:
|
|
122
|
+
return "N/A"
|
|
123
|
+
return f"{value:.2f}"
|