@drico2008/fincli 0.3.0 → 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.
Files changed (37) hide show
  1. package/README.md +217 -217
  2. package/fincli/__init__.py +1 -1
  3. package/fincli/app/analysis/ai_prompts.py +29 -27
  4. package/fincli/app/analysis/analyzer.py +34 -34
  5. package/fincli/app/analysis/assistant_context.py +3 -3
  6. package/fincli/app/cli/commands.py +33 -27
  7. package/fincli/app/cli/router.py +1633 -1105
  8. package/fincli/app/diagnostics/__init__.py +2 -0
  9. package/fincli/app/diagnostics/capabilities.py +44 -0
  10. package/fincli/app/diagnostics/runtime.py +106 -0
  11. package/fincli/app/main.py +6 -1
  12. package/fincli/app/modules/economic_calendar.py +512 -512
  13. package/fincli/app/modules/portfolio_risk.py +305 -305
  14. package/fincli/app/modules/trading.py +142 -0
  15. package/fincli/app/plugins/loader.py +72 -72
  16. package/fincli/app/providers/market/finnhub_provider.py +51 -2
  17. package/fincli/app/providers/market/symbols.py +95 -2
  18. package/fincli/app/providers/reliability.py +82 -65
  19. package/fincli/app/research/__init__.py +8 -8
  20. package/fincli/app/research/engine.py +119 -112
  21. package/fincli/app/research/exporter.py +91 -91
  22. package/fincli/app/research/formatter.py +25 -24
  23. package/fincli/app/research/models.py +22 -21
  24. package/fincli/app/research/prompt_builder.py +53 -51
  25. package/fincli/app/services/data_quality.py +27 -0
  26. package/fincli/app/services/data_trust.py +117 -0
  27. package/fincli/app/services/macro_data.py +158 -50
  28. package/fincli/app/services/market_data.py +183 -79
  29. package/fincli/app/services/market_overview.py +131 -142
  30. package/fincli/app/services/news_aggregator.py +95 -95
  31. package/fincli/app/storage/config.py +6 -3
  32. package/fincli/app/storage/database.py +130 -117
  33. package/fincli/app/storage/provider_metrics.py +61 -61
  34. package/fincli/app/storage/secrets.py +128 -128
  35. package/npm/bin/fincli.js +65 -42
  36. package/package.json +7 -7
  37. 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
+ )