@drico2008/fincli 0.3.1 → 0.4.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/README.md +217 -217
- package/fincli/__init__.py +1 -1
- package/fincli/app/analysis/ai_prompts.py +29 -27
- package/fincli/app/analysis/analyzer.py +34 -34
- package/fincli/app/analysis/assistant_context.py +3 -3
- package/fincli/app/cli/commands.py +33 -27
- package/fincli/app/cli/router.py +1633 -1105
- package/fincli/app/diagnostics/__init__.py +2 -0
- package/fincli/app/diagnostics/capabilities.py +44 -0
- package/fincli/app/diagnostics/runtime.py +106 -0
- package/fincli/app/main.py +6 -1
- package/fincli/app/modules/economic_calendar.py +512 -512
- package/fincli/app/modules/portfolio_risk.py +305 -305
- package/fincli/app/modules/trading.py +142 -0
- package/fincli/app/plugins/loader.py +72 -72
- package/fincli/app/providers/market/finnhub_provider.py +51 -2
- package/fincli/app/providers/market/symbols.py +95 -2
- package/fincli/app/providers/reliability.py +82 -65
- package/fincli/app/research/__init__.py +8 -8
- package/fincli/app/research/engine.py +119 -112
- package/fincli/app/research/exporter.py +91 -91
- package/fincli/app/research/formatter.py +25 -24
- package/fincli/app/research/models.py +22 -21
- package/fincli/app/research/prompt_builder.py +53 -51
- package/fincli/app/services/data_quality.py +27 -0
- package/fincli/app/services/data_trust.py +117 -0
- package/fincli/app/services/macro_data.py +158 -50
- package/fincli/app/services/market_data.py +183 -79
- package/fincli/app/services/market_overview.py +131 -142
- package/fincli/app/services/news_aggregator.py +95 -95
- package/fincli/app/storage/config.py +6 -3
- package/fincli/app/storage/database.py +130 -117
- package/fincli/app/storage/provider_metrics.py +61 -61
- package/fincli/app/storage/secrets.py +128 -128
- package/npm/bin/fincli.js +65 -65
- package/package.json +7 -7
- package/pyproject.toml +1 -1
|
@@ -1,305 +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
|
-
)
|
|
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
|
+
)
|