Mbonea commited on
Commit
f47d435
Β·
1 Parent(s): 3a7de39

Add risk-adjusted volatility allocation analysis

Browse files
App/analysis/portfolio_optimizer.py CHANGED
@@ -1,12 +1,13 @@
1
  import asyncio
2
- import math
3
  from dataclasses import dataclass
4
  from datetime import date, timedelta
5
  from decimal import Decimal, InvalidOperation
 
6
  from typing import Any
7
 
8
  import numpy as np
9
 
 
10
  from App.routers.bonds.models import Bond
11
  from App.routers.funds.models import FundPerformance, MutualFund
12
  from App.routers.stocks.metrics import calculate_metrics
@@ -20,6 +21,32 @@ GROWTH_CLASS_LIMITS = {
20
  "BOND": (0.05, 0.35),
21
  }
22
  MAX_ASSET_WEIGHT = 0.35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  DEFAULT_RISK_FREE_RATE = 0.12
24
  DEFAULT_SIMULATIONS = 6000
25
 
@@ -36,9 +63,53 @@ class OptimizerAsset:
36
  current_value: float
37
  expected_return: float
38
  volatility: float
 
39
  fee_rate: float
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  def _safe_float(value: Any, default: float = 0.0) -> float:
43
  if value in (None, ""):
44
  return default
@@ -59,16 +130,6 @@ def _annualized_return(values: list[tuple[date, float]]) -> float | None:
59
  return (1 + total_return) ** (365 / days) - 1
60
 
61
 
62
- def _annualized_volatility(values: list[tuple[date, float]]) -> float | None:
63
- ordered = [v for _, v in sorted(values, key=lambda item: item[0]) if v > 0]
64
- if len(ordered) < 3:
65
- return None
66
- returns = np.diff(np.log(np.array(ordered, dtype=float)))
67
- if len(returns) == 0:
68
- return None
69
- return float(np.std(returns) * math.sqrt(252))
70
-
71
-
72
  def _stock_fee_rate(consideration: float) -> float:
73
  payload = load_dse_transaction_fee_seed_data()
74
  for band in payload.get("bands", []):
@@ -88,6 +149,33 @@ def _parse_percent(raw_value: Any) -> float:
88
  return _safe_float(digits) / 100 if digits else 0.0
89
 
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  async def _collect_positions(portfolio_id: int) -> list[dict[str, Any]]:
92
  from App.routers.portfolio.service import PortfolioService
93
 
@@ -110,7 +198,7 @@ async def _build_stock_asset(position: dict[str, Any]) -> OptimizerAsset | None:
110
 
111
  prices = await StockPriceData.filter(
112
  stock_id=stock.id,
113
- date__gte=date.today() - timedelta(days=365 * 3),
114
  ).order_by("date").values("date", "closing_price")
115
  price_values = [(row["date"], _safe_float(row["closing_price"])) for row in prices]
116
  latest = await StockPriceData.filter(stock=stock).order_by("-date").first()
@@ -121,7 +209,10 @@ async def _build_stock_asset(position: dict[str, Any]) -> OptimizerAsset | None:
121
  capital_return = _annualized_return(price_values)
122
  dividend_yield = _safe_float(metrics.get("dividend_yield")) / 100
123
  expected_return = (capital_return if capital_return is not None else 0.10) + dividend_yield
124
- volatility = _annualized_volatility(price_values) or 0.28
 
 
 
125
  current_price = _safe_float(latest.closing_price)
126
  quantity = _safe_float(position["quantity"])
127
  current_value = quantity * current_price
@@ -137,6 +228,7 @@ async def _build_stock_asset(position: dict[str, Any]) -> OptimizerAsset | None:
137
  current_value=current_value,
138
  expected_return=max(min(expected_return, 0.60), -0.30),
139
  volatility=max(volatility, 0.08),
 
140
  fee_rate=_stock_fee_rate(current_value),
141
  )
142
 
@@ -148,7 +240,7 @@ async def _build_fund_asset(position: dict[str, Any]) -> OptimizerAsset | None:
148
 
149
  rows = await FundPerformance.filter(
150
  fund_id=fund.id,
151
- record_date__gte=date.today() - timedelta(days=365 * 3),
152
  ).order_by("record_date").values("record_date", "nav_per_unit")
153
  nav_values = [
154
  (row["record_date"], _safe_float(row["nav_per_unit"]))
@@ -159,8 +251,12 @@ async def _build_fund_asset(position: dict[str, Any]) -> OptimizerAsset | None:
159
  if latest is None or latest.nav_per_unit is None:
160
  return None
161
 
162
- expected_return = _annualized_return(nav_values) or 0.13
163
- volatility = _annualized_volatility(nav_values) or 0.08
 
 
 
 
164
  current_price = _safe_float(latest.nav_per_unit)
165
  quantity = _safe_float(position["quantity"])
166
  current_value = quantity * current_price
@@ -176,6 +272,7 @@ async def _build_fund_asset(position: dict[str, Any]) -> OptimizerAsset | None:
176
  current_value=current_value,
177
  expected_return=max(min(expected_return, 0.35), 0.03),
178
  volatility=max(volatility, 0.03),
 
179
  fee_rate=_safe_float(fund.entry_load) / 100,
180
  )
181
 
@@ -202,6 +299,7 @@ async def _build_bond_asset(position: dict[str, Any]) -> OptimizerAsset | None:
202
  current_value=current_value,
203
  expected_return=max(coupon_rate, 0.10),
204
  volatility=0.04,
 
205
  fee_rate=0.0,
206
  )
207
 
@@ -223,6 +321,81 @@ async def _build_assets(portfolio_id: int) -> list[OptimizerAsset]:
223
  return assets
224
 
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  def _portfolio_stats(weights: np.ndarray, returns: np.ndarray, vols: np.ndarray) -> dict:
227
  portfolio_return = float(np.dot(weights, returns))
228
  portfolio_vol = float(np.sqrt(np.dot(weights**2, vols**2)))
@@ -245,8 +418,8 @@ def _class_totals(weights: np.ndarray, assets: list[OptimizerAsset]) -> dict[str
245
  return totals
246
 
247
 
248
- def _respects_limits(weights: np.ndarray, assets: list[OptimizerAsset]) -> bool:
249
- effective_max_asset_weight = max(MAX_ASSET_WEIGHT, 1 / len(assets))
250
  if np.any(weights < 0) or np.any(weights > effective_max_asset_weight):
251
  return False
252
  available_classes = {asset.asset_type for asset in assets}
@@ -254,9 +427,10 @@ def _respects_limits(weights: np.ndarray, assets: list[OptimizerAsset]) -> bool:
254
  return True
255
 
256
  class_totals = _class_totals(weights, assets)
 
257
  for asset_type in available_classes:
258
  total = class_totals.get(asset_type, 0.0)
259
- min_weight, max_weight = GROWTH_CLASS_LIMITS[asset_type]
260
  if total < min_weight or total > max_weight:
261
  return False
262
  return True
@@ -268,11 +442,52 @@ def _estimate_rebalance_fees(
268
  assets: list[OptimizerAsset],
269
  total_value: float,
270
  ) -> float:
271
- fees = 0.0
 
 
 
 
 
 
 
 
 
272
  for current, suggested, asset in zip(current_weights, suggested_weights, assets):
273
  increase = max(0.0, float(suggested - current)) * total_value
274
- fees += increase * asset.fee_rate
275
- return fees
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
 
278
  def _advisor_comment(
@@ -280,6 +495,7 @@ def _advisor_comment(
280
  suggested_stats: dict[str, float],
281
  allocations: list[dict[str, Any]],
282
  estimated_fees: float,
 
283
  ) -> dict[str, Any]:
284
  increases = [item for item in allocations if item["difference"] > 0.03]
285
  reductions = [item for item in allocations if item["difference"] < -0.03]
@@ -289,7 +505,7 @@ def _advisor_comment(
289
  return_delta = suggested_stats["expected_return"] - current_stats["expected_return"]
290
 
291
  summary = (
292
- "The suggested allocation keeps the portfolio growth-focused while trying "
293
  "to avoid one position carrying too much of the risk."
294
  )
295
  if sharpe_delta > 0.05:
@@ -325,17 +541,25 @@ def _advisor_comment(
325
  "key_actions": actions[:3],
326
  "caution": (
327
  "This is an allocation model, not a guarantee. It uses historical prices, NAVs, "
328
- "bond coupons, and known transaction costs; financial statement quality and future news still need human review."
329
  ),
330
  }
331
 
332
 
333
- def _optimize_sync(assets_payload: list[dict[str, Any]], simulations: int) -> dict[str, Any]:
 
 
 
 
 
 
334
  assets = [OptimizerAsset(**payload) for payload in assets_payload]
 
335
  total_value = sum(asset.current_value for asset in assets)
336
  current_weights = np.array([asset.current_value / total_value for asset in assets])
337
  returns = np.array([asset.expected_return for asset in assets])
338
  vols = np.array([asset.volatility for asset in assets])
 
339
 
340
  best_weights = current_weights.copy()
341
  best_score = -float("inf")
@@ -344,12 +568,19 @@ def _optimize_sync(assets_payload: list[dict[str, Any]], simulations: int) -> di
344
 
345
  for _ in range(attempts):
346
  weights = rng.dirichlet(np.ones(len(assets)))
347
- if not _respects_limits(weights, assets):
348
  continue
349
  stats = _portfolio_stats(weights, returns, vols)
350
  fees = _estimate_rebalance_fees(current_weights, weights, assets, total_value)
351
  fee_drag = fees / total_value if total_value else 0.0
352
- score = stats["sharpe"] + stats["expected_return"] - fee_drag
 
 
 
 
 
 
 
353
  if score > best_score:
354
  best_score = score
355
  best_weights = weights
@@ -359,6 +590,8 @@ def _optimize_sync(assets_payload: list[dict[str, Any]], simulations: int) -> di
359
  estimated_fees = _estimate_rebalance_fees(
360
  current_weights, best_weights, assets, total_value
361
  )
 
 
362
 
363
  allocations = []
364
  for asset, current_weight, suggested_weight in zip(
@@ -384,6 +617,7 @@ def _optimize_sync(assets_payload: list[dict[str, Any]], simulations: int) -> di
384
  "rebalance_amount": round(difference * total_value, 2),
385
  "expected_return": round(asset.expected_return, 4),
386
  "volatility": round(asset.volatility, 4),
 
387
  "fee_rate": round(asset.fee_rate, 4),
388
  "reason": reason,
389
  }
@@ -397,27 +631,33 @@ def _optimize_sync(assets_payload: list[dict[str, Any]], simulations: int) -> di
397
 
398
  return {
399
  "objective": "growth",
 
 
 
400
  "total_value": round(total_value, 2),
401
  "risk_free_rate": DEFAULT_RISK_FREE_RATE,
402
  "constraints": {
403
- "max_asset_weight": max(MAX_ASSET_WEIGHT, 1 / len(assets)),
404
- "class_limits": GROWTH_CLASS_LIMITS,
405
  "class_limits_apply_only_when_multiple_asset_classes_exist": True,
406
  },
407
  "current": {k: round(v, 4) for k, v in current_stats.items()},
408
  "suggested": {k: round(v, 4) for k, v in suggested_stats.items()},
409
  "estimated_rebalance_fees": round(estimated_fees, 2),
 
410
  "advisor_comment": _advisor_comment(
411
  current_stats,
412
  suggested_stats,
413
  sorted_allocations,
414
  estimated_fees,
 
415
  ),
416
  "allocations": sorted_allocations,
 
417
  "notes": [
418
- "Uses current portfolio assets only; it does not yet recommend brand-new securities.",
419
  "Stock buy fee estimates use the stored DSE transaction fee bands.",
420
- "Fund buy fee estimates use each fund's entry_load where available.",
421
  "Optimization uses Monte Carlo simulation to stay lightweight on Hugging Face.",
422
  ],
423
  }
@@ -426,10 +666,14 @@ def _optimize_sync(assets_payload: list[dict[str, Any]], simulations: int) -> di
426
  async def analyze_portfolio_growth_allocation(
427
  portfolio_id: int,
428
  simulations: int = DEFAULT_SIMULATIONS,
 
 
429
  ) -> dict[str, Any]:
430
  assets = await _build_assets(portfolio_id)
431
  if not assets:
432
  raise ValueError("At least one priced asset is required for allocation analysis")
 
 
433
  if len(assets) == 1:
434
  asset = assets[0]
435
  current_stats = {
@@ -459,6 +703,9 @@ async def analyze_portfolio_growth_allocation(
459
  }
460
  return {
461
  "objective": "growth",
 
 
 
462
  "total_value": round(asset.current_value, 2),
463
  "risk_free_rate": DEFAULT_RISK_FREE_RATE,
464
  "constraints": {
@@ -469,6 +716,7 @@ async def analyze_portfolio_growth_allocation(
469
  "current": {k: round(v, 4) for k, v in current_stats.items()},
470
  "suggested": {k: round(v, 4) for k, v in current_stats.items()},
471
  "estimated_rebalance_fees": 0.0,
 
472
  "advisor_comment": {
473
  "title": "Concentration warning",
474
  "summary": (
@@ -487,6 +735,7 @@ async def analyze_portfolio_growth_allocation(
487
  ),
488
  },
489
  "allocations": [allocation],
 
490
  "notes": [
491
  "Single-asset portfolios receive a concentration analysis instead of a failed optimization.",
492
  "Add at least two priced assets to enable portfolio rebalancing advice.",
@@ -496,6 +745,9 @@ async def analyze_portfolio_growth_allocation(
496
  _optimize_sync,
497
  [asset.__dict__ for asset in assets],
498
  simulations,
 
 
 
499
  )
500
 
501
 
@@ -503,16 +755,20 @@ async def run_portfolio_analysis_task(
503
  task_id: int,
504
  portfolio_id: int,
505
  simulations: int = DEFAULT_SIMULATIONS,
 
 
506
  ) -> None:
507
  await ImportTask.filter(id=task_id).update(
508
  status=TaskStatus.RUNNING,
509
  details={
510
  "portfolio_id": portfolio_id,
 
 
511
  "status_message": "Portfolio growth allocation analysis is running",
512
  },
513
  )
514
  try:
515
- result = await analyze_portfolio_growth_allocation(portfolio_id, simulations)
516
  except Exception as exc:
517
  await ImportTask.filter(id=task_id).update(
518
  status=TaskStatus.FAILED,
 
1
  import asyncio
 
2
  from dataclasses import dataclass
3
  from datetime import date, timedelta
4
  from decimal import Decimal, InvalidOperation
5
+ import re
6
  from typing import Any
7
 
8
  import numpy as np
9
 
10
+ from App.analysis.volatility import calculate_annualized_volatility
11
  from App.routers.bonds.models import Bond
12
  from App.routers.funds.models import FundPerformance, MutualFund
13
  from App.routers.stocks.metrics import calculate_metrics
 
21
  "BOND": (0.05, 0.35),
22
  }
23
  MAX_ASSET_WEIGHT = 0.35
24
+ RISK_PROFILES = {
25
+ "conservative": {
26
+ "label": "Conservative",
27
+ "class_limits": {"STOCK": (0.00, 0.35), "FUND": (0.25, 0.70), "BOND": (0.15, 0.60)},
28
+ "max_asset_weight": 0.25,
29
+ "return_weight": 0.55,
30
+ "volatility_penalty": 1.35,
31
+ "income_weight": 0.45,
32
+ },
33
+ "balanced": {
34
+ "label": "Balanced",
35
+ "class_limits": GROWTH_CLASS_LIMITS,
36
+ "max_asset_weight": MAX_ASSET_WEIGHT,
37
+ "return_weight": 0.85,
38
+ "volatility_penalty": 0.85,
39
+ "income_weight": 0.25,
40
+ },
41
+ "growth": {
42
+ "label": "Growth",
43
+ "class_limits": {"STOCK": (0.30, 0.75), "FUND": (0.05, 0.45), "BOND": (0.00, 0.25)},
44
+ "max_asset_weight": 0.40,
45
+ "return_weight": 1.15,
46
+ "volatility_penalty": 0.45,
47
+ "income_weight": 0.10,
48
+ },
49
+ }
50
  DEFAULT_RISK_FREE_RATE = 0.12
51
  DEFAULT_SIMULATIONS = 6000
52
 
 
63
  current_value: float
64
  expected_return: float
65
  volatility: float
66
+ income_yield: float
67
  fee_rate: float
68
 
69
 
70
+ def _blend_pair(start: tuple[float, float], end: tuple[float, float], t: float) -> tuple[float, float]:
71
+ return (
72
+ start[0] + (end[0] - start[0]) * t,
73
+ start[1] + (end[1] - start[1]) * t,
74
+ )
75
+
76
+
77
+ def _risk_settings(risk_profile: str | None, risk_score: int | None = None) -> dict[str, Any]:
78
+ if risk_score is not None:
79
+ score = max(0, min(100, int(risk_score)))
80
+ t = score / 100
81
+ low = RISK_PROFILES["conservative"]
82
+ high = RISK_PROFILES["growth"]
83
+ if score < 34:
84
+ label = "Conservative"
85
+ elif score > 66:
86
+ label = "Aggressive"
87
+ else:
88
+ label = "Balanced"
89
+ return {
90
+ "key": "conservative" if score < 34 else "growth" if score > 66 else "balanced",
91
+ "label": label,
92
+ "risk_score": score,
93
+ "class_limits": {
94
+ asset_type: _blend_pair(low["class_limits"][asset_type], high["class_limits"][asset_type], t)
95
+ for asset_type in GROWTH_CLASS_LIMITS
96
+ },
97
+ "max_asset_weight": low["max_asset_weight"] + (high["max_asset_weight"] - low["max_asset_weight"]) * t,
98
+ "return_weight": low["return_weight"] + (high["return_weight"] - low["return_weight"]) * t,
99
+ "volatility_penalty": low["volatility_penalty"] + (high["volatility_penalty"] - low["volatility_penalty"]) * t,
100
+ "income_weight": low["income_weight"] + (high["income_weight"] - low["income_weight"]) * t,
101
+ }
102
+
103
+ key = str(risk_profile or "balanced").strip().lower()
104
+ if key in {"low", "safe", "safer", "defensive"}:
105
+ key = "conservative"
106
+ elif key in {"high", "aggressive"}:
107
+ key = "growth"
108
+ settings = RISK_PROFILES.get(key, RISK_PROFILES["balanced"])
109
+ fallback_score = 15 if key == "conservative" else 85 if key == "growth" else 50
110
+ return {"key": key if key in RISK_PROFILES else "balanced", "risk_score": fallback_score, **settings}
111
+
112
+
113
  def _safe_float(value: Any, default: float = 0.0) -> float:
114
  if value in (None, ""):
115
  return default
 
130
  return (1 + total_return) ** (365 / days) - 1
131
 
132
 
 
 
 
 
 
 
 
 
 
 
133
  def _stock_fee_rate(consideration: float) -> float:
134
  payload = load_dse_transaction_fee_seed_data()
135
  for band in payload.get("bands", []):
 
149
  return _safe_float(digits) / 100 if digits else 0.0
150
 
151
 
152
+ def _parse_distribution_rate(raw_value: Any) -> float:
153
+ text = str(raw_value or "")
154
+ matches = re.findall(r"(\d+(?:\.\d+)?)\s*%", text)
155
+ if not matches:
156
+ return 0.0
157
+ return max(_safe_float(match) / 100 for match in matches)
158
+
159
+
160
+ async def _fund_income_yield(fund: MutualFund) -> float:
161
+ if not fund.pays_income:
162
+ return 0.0
163
+ try:
164
+ info = await fund.info_record
165
+ except Exception:
166
+ info = None
167
+ data = info.raw_data if info and isinstance(info.raw_data, dict) else {}
168
+ distribution = data.get("distribution") if isinstance(data.get("distribution"), dict) else {}
169
+ other_facts = data.get("other_facts") if isinstance(data.get("other_facts"), dict) else {}
170
+ candidates = [
171
+ fund.income_amount,
172
+ distribution.get("max_distribution_rate"),
173
+ distribution.get("policy"),
174
+ other_facts.get("max_distribution_rate"),
175
+ ]
176
+ return min(max((_parse_distribution_rate(value) for value in candidates), default=0.0), 0.18)
177
+
178
+
179
  async def _collect_positions(portfolio_id: int) -> list[dict[str, Any]]:
180
  from App.routers.portfolio.service import PortfolioService
181
 
 
198
 
199
  prices = await StockPriceData.filter(
200
  stock_id=stock.id,
201
+ date__gte=date.today() - timedelta(days=365 * 5 + 7),
202
  ).order_by("date").values("date", "closing_price")
203
  price_values = [(row["date"], _safe_float(row["closing_price"])) for row in prices]
204
  latest = await StockPriceData.filter(stock=stock).order_by("-date").first()
 
209
  capital_return = _annualized_return(price_values)
210
  dividend_yield = _safe_float(metrics.get("dividend_yield")) / 100
211
  expected_return = (capital_return if capital_return is not None else 0.10) + dividend_yield
212
+ volatility_result = calculate_annualized_volatility(price_values)
213
+ volatility = (
214
+ volatility_result.annualized_volatility if volatility_result else 0.28
215
+ )
216
  current_price = _safe_float(latest.closing_price)
217
  quantity = _safe_float(position["quantity"])
218
  current_value = quantity * current_price
 
228
  current_value=current_value,
229
  expected_return=max(min(expected_return, 0.60), -0.30),
230
  volatility=max(volatility, 0.08),
231
+ income_yield=max(dividend_yield, 0.0),
232
  fee_rate=_stock_fee_rate(current_value),
233
  )
234
 
 
240
 
241
  rows = await FundPerformance.filter(
242
  fund_id=fund.id,
243
+ record_date__gte=date.today() - timedelta(days=365 * 5 + 7),
244
  ).order_by("record_date").values("record_date", "nav_per_unit")
245
  nav_values = [
246
  (row["record_date"], _safe_float(row["nav_per_unit"]))
 
251
  if latest is None or latest.nav_per_unit is None:
252
  return None
253
 
254
+ income_yield = await _fund_income_yield(fund)
255
+ expected_return = (_annualized_return(nav_values) or 0.13) + income_yield
256
+ volatility_result = calculate_annualized_volatility(nav_values)
257
+ volatility = (
258
+ volatility_result.annualized_volatility if volatility_result else 0.08
259
+ )
260
  current_price = _safe_float(latest.nav_per_unit)
261
  quantity = _safe_float(position["quantity"])
262
  current_value = quantity * current_price
 
272
  current_value=current_value,
273
  expected_return=max(min(expected_return, 0.35), 0.03),
274
  volatility=max(volatility, 0.03),
275
+ income_yield=income_yield,
276
  fee_rate=_safe_float(fund.entry_load) / 100,
277
  )
278
 
 
299
  current_value=current_value,
300
  expected_return=max(coupon_rate, 0.10),
301
  volatility=0.04,
302
+ income_yield=coupon_rate,
303
  fee_rate=0.0,
304
  )
305
 
 
321
  return assets
322
 
323
 
324
+ async def _stock_candidate(stock: Stock) -> dict[str, Any] | None:
325
+ asset = await _build_stock_asset({"asset_id": stock.id, "quantity": Decimal("1")})
326
+ if asset is None:
327
+ return None
328
+ return {
329
+ "asset_id": asset.asset_id,
330
+ "asset_type": asset.asset_type,
331
+ "symbol": asset.symbol,
332
+ "name": asset.name,
333
+ "expected_return": round(asset.expected_return, 4),
334
+ "volatility": round(asset.volatility, 4),
335
+ "income_yield": round(asset.income_yield, 4),
336
+ "reason": "Lower-volatility stock candidate with available price and dividend history.",
337
+ }
338
+
339
+
340
+ async def _fund_candidate(fund: MutualFund) -> dict[str, Any] | None:
341
+ asset = await _build_fund_asset({"asset_id": fund.id, "quantity": Decimal("1")})
342
+ if asset is None:
343
+ return None
344
+ return {
345
+ "asset_id": asset.asset_id,
346
+ "asset_type": asset.asset_type,
347
+ "symbol": asset.symbol,
348
+ "name": asset.name,
349
+ "expected_return": round(asset.expected_return, 4),
350
+ "volatility": round(asset.volatility, 4),
351
+ "income_yield": round(asset.income_yield, 4),
352
+ "pays_income": asset.income_yield > 0,
353
+ "reason": (
354
+ "Income-paying fund candidate with lower measured volatility."
355
+ if asset.income_yield > 0
356
+ else "Fund candidate with lower measured volatility."
357
+ ),
358
+ }
359
+
360
+
361
+ async def _suggest_alternatives(current_assets: list[OptimizerAsset], risk_profile: str | None, risk_score: int | None) -> list[dict[str, Any]]:
362
+ current_keys = {asset.key for asset in current_assets}
363
+ settings = _risk_settings(risk_profile, risk_score)
364
+ candidates: list[dict[str, Any]] = []
365
+
366
+ funds = await MutualFund.filter(status="Active").all()
367
+ for fund in funds:
368
+ if f"FUND:{fund.id}" in current_keys:
369
+ continue
370
+ candidate = await _fund_candidate(fund)
371
+ if candidate:
372
+ candidates.append(candidate)
373
+
374
+ stocks = await Stock.all()
375
+ for stock in stocks:
376
+ if f"STOCK:{stock.id}" in current_keys:
377
+ continue
378
+ candidate = await _stock_candidate(stock)
379
+ if candidate:
380
+ candidates.append(candidate)
381
+
382
+ if not candidates:
383
+ return []
384
+
385
+ current_volatility = max((asset.volatility for asset in current_assets), default=0.0)
386
+ max_volatility = current_volatility * (0.85 if settings["key"] == "conservative" else 1.10)
387
+ safer = [item for item in candidates if item["volatility"] <= max_volatility] or candidates
388
+
389
+ def score(item: dict[str, Any]) -> float:
390
+ return (
391
+ item["expected_return"] * settings["return_weight"]
392
+ + item.get("income_yield", 0.0) * settings["income_weight"]
393
+ - item["volatility"] * settings["volatility_penalty"]
394
+ )
395
+
396
+ return sorted(safer, key=score, reverse=True)[:5]
397
+
398
+
399
  def _portfolio_stats(weights: np.ndarray, returns: np.ndarray, vols: np.ndarray) -> dict:
400
  portfolio_return = float(np.dot(weights, returns))
401
  portfolio_vol = float(np.sqrt(np.dot(weights**2, vols**2)))
 
418
  return totals
419
 
420
 
421
+ def _respects_limits(weights: np.ndarray, assets: list[OptimizerAsset], settings: dict[str, Any]) -> bool:
422
+ effective_max_asset_weight = max(float(settings["max_asset_weight"]), 1 / len(assets))
423
  if np.any(weights < 0) or np.any(weights > effective_max_asset_weight):
424
  return False
425
  available_classes = {asset.asset_type for asset in assets}
 
427
  return True
428
 
429
  class_totals = _class_totals(weights, assets)
430
+ class_limits = settings["class_limits"]
431
  for asset_type in available_classes:
432
  total = class_totals.get(asset_type, 0.0)
433
+ min_weight, max_weight = class_limits[asset_type]
434
  if total < min_weight or total > max_weight:
435
  return False
436
  return True
 
442
  assets: list[OptimizerAsset],
443
  total_value: float,
444
  ) -> float:
445
+ return sum(item["estimated_fee"] for item in _fee_breakdown(current_weights, suggested_weights, assets, total_value))
446
+
447
+
448
+ def _fee_breakdown(
449
+ current_weights: np.ndarray,
450
+ suggested_weights: np.ndarray,
451
+ assets: list[OptimizerAsset],
452
+ total_value: float,
453
+ ) -> list[dict[str, Any]]:
454
+ items = []
455
  for current, suggested, asset in zip(current_weights, suggested_weights, assets):
456
  increase = max(0.0, float(suggested - current)) * total_value
457
+ if increase <= 0 or asset.fee_rate <= 0:
458
+ continue
459
+ fee = increase * asset.fee_rate
460
+ items.append(
461
+ {
462
+ "asset_id": asset.asset_id,
463
+ "asset_type": asset.asset_type,
464
+ "symbol": asset.symbol,
465
+ "name": asset.name,
466
+ "buy_amount": round(increase, 2),
467
+ "fee_rate": round(asset.fee_rate, 6),
468
+ "estimated_fee": round(fee, 2),
469
+ "fee_source": (
470
+ "DSE transaction fee band"
471
+ if asset.asset_type == "STOCK"
472
+ else "Fund entry load"
473
+ if asset.asset_type == "FUND"
474
+ else "No explicit buy fee"
475
+ ),
476
+ }
477
+ )
478
+ return sorted(items, key=lambda item: item["estimated_fee"], reverse=True)
479
+
480
+
481
+ def _fee_summary(fee_items: list[dict[str, Any]]) -> dict[str, Any]:
482
+ by_type: dict[str, float] = {}
483
+ for item in fee_items:
484
+ by_type[item["asset_type"]] = by_type.get(item["asset_type"], 0.0) + item["estimated_fee"]
485
+ total = sum(by_type.values())
486
+ return {
487
+ "total": round(total, 2),
488
+ "by_asset_type": {key: round(value, 2) for key, value in by_type.items()},
489
+ "items": fee_items,
490
+ }
491
 
492
 
493
  def _advisor_comment(
 
495
  suggested_stats: dict[str, float],
496
  allocations: list[dict[str, Any]],
497
  estimated_fees: float,
498
+ risk_label: str,
499
  ) -> dict[str, Any]:
500
  increases = [item for item in allocations if item["difference"] > 0.03]
501
  reductions = [item for item in allocations if item["difference"] < -0.03]
 
505
  return_delta = suggested_stats["expected_return"] - current_stats["expected_return"]
506
 
507
  summary = (
508
+ f"The suggested allocation is tuned for a {risk_label.lower()} risk setting while trying "
509
  "to avoid one position carrying too much of the risk."
510
  )
511
  if sharpe_delta > 0.05:
 
541
  "key_actions": actions[:3],
542
  "caution": (
543
  "This is an allocation model, not a guarantee. It uses historical prices, NAVs, "
544
+ "dividends, fund payout flags, bond coupons, and known transaction costs; financial statement quality and future news still need human review."
545
  ),
546
  }
547
 
548
 
549
+ def _optimize_sync(
550
+ assets_payload: list[dict[str, Any]],
551
+ simulations: int,
552
+ risk_profile: str | None,
553
+ risk_score: int | None,
554
+ alternatives: list[dict[str, Any]],
555
+ ) -> dict[str, Any]:
556
  assets = [OptimizerAsset(**payload) for payload in assets_payload]
557
+ settings = _risk_settings(risk_profile, risk_score)
558
  total_value = sum(asset.current_value for asset in assets)
559
  current_weights = np.array([asset.current_value / total_value for asset in assets])
560
  returns = np.array([asset.expected_return for asset in assets])
561
  vols = np.array([asset.volatility for asset in assets])
562
+ income_yields = np.array([asset.income_yield for asset in assets])
563
 
564
  best_weights = current_weights.copy()
565
  best_score = -float("inf")
 
568
 
569
  for _ in range(attempts):
570
  weights = rng.dirichlet(np.ones(len(assets)))
571
+ if not _respects_limits(weights, assets, settings):
572
  continue
573
  stats = _portfolio_stats(weights, returns, vols)
574
  fees = _estimate_rebalance_fees(current_weights, weights, assets, total_value)
575
  fee_drag = fees / total_value if total_value else 0.0
576
+ income_score = float(np.dot(weights, income_yields))
577
+ score = (
578
+ stats["sharpe"]
579
+ + settings["return_weight"] * stats["expected_return"]
580
+ + settings["income_weight"] * income_score
581
+ - settings["volatility_penalty"] * stats["volatility"]
582
+ - fee_drag
583
+ )
584
  if score > best_score:
585
  best_score = score
586
  best_weights = weights
 
590
  estimated_fees = _estimate_rebalance_fees(
591
  current_weights, best_weights, assets, total_value
592
  )
593
+ fee_items = _fee_breakdown(current_weights, best_weights, assets, total_value)
594
+ fee_summary = _fee_summary(fee_items)
595
 
596
  allocations = []
597
  for asset, current_weight, suggested_weight in zip(
 
617
  "rebalance_amount": round(difference * total_value, 2),
618
  "expected_return": round(asset.expected_return, 4),
619
  "volatility": round(asset.volatility, 4),
620
+ "income_yield": round(asset.income_yield, 4),
621
  "fee_rate": round(asset.fee_rate, 4),
622
  "reason": reason,
623
  }
 
631
 
632
  return {
633
  "objective": "growth",
634
+ "risk_profile": settings["key"],
635
+ "risk_label": settings["label"],
636
+ "risk_score": settings["risk_score"],
637
  "total_value": round(total_value, 2),
638
  "risk_free_rate": DEFAULT_RISK_FREE_RATE,
639
  "constraints": {
640
+ "max_asset_weight": max(float(settings["max_asset_weight"]), 1 / len(assets)),
641
+ "class_limits": settings["class_limits"],
642
  "class_limits_apply_only_when_multiple_asset_classes_exist": True,
643
  },
644
  "current": {k: round(v, 4) for k, v in current_stats.items()},
645
  "suggested": {k: round(v, 4) for k, v in suggested_stats.items()},
646
  "estimated_rebalance_fees": round(estimated_fees, 2),
647
+ "fee_breakdown": fee_summary,
648
  "advisor_comment": _advisor_comment(
649
  current_stats,
650
  suggested_stats,
651
  sorted_allocations,
652
  estimated_fees,
653
+ settings["label"],
654
  ),
655
  "allocations": sorted_allocations,
656
+ "alternatives": alternatives,
657
  "notes": [
658
+ "Suggested allocations rebalance current holdings; alternatives identify lower-volatility assets to research before adding new positions.",
659
  "Stock buy fee estimates use the stored DSE transaction fee bands.",
660
+ "Stock dividends, fund income flags, bond coupons, and fund entry loads are included where available.",
661
  "Optimization uses Monte Carlo simulation to stay lightweight on Hugging Face.",
662
  ],
663
  }
 
666
  async def analyze_portfolio_growth_allocation(
667
  portfolio_id: int,
668
  simulations: int = DEFAULT_SIMULATIONS,
669
+ risk_profile: str | None = None,
670
+ risk_score: int | None = None,
671
  ) -> dict[str, Any]:
672
  assets = await _build_assets(portfolio_id)
673
  if not assets:
674
  raise ValueError("At least one priced asset is required for allocation analysis")
675
+ alternatives = await _suggest_alternatives(assets, risk_profile, risk_score)
676
+ settings = _risk_settings(risk_profile, risk_score)
677
  if len(assets) == 1:
678
  asset = assets[0]
679
  current_stats = {
 
703
  }
704
  return {
705
  "objective": "growth",
706
+ "risk_profile": settings["key"],
707
+ "risk_label": settings["label"],
708
+ "risk_score": settings["risk_score"],
709
  "total_value": round(asset.current_value, 2),
710
  "risk_free_rate": DEFAULT_RISK_FREE_RATE,
711
  "constraints": {
 
716
  "current": {k: round(v, 4) for k, v in current_stats.items()},
717
  "suggested": {k: round(v, 4) for k, v in current_stats.items()},
718
  "estimated_rebalance_fees": 0.0,
719
+ "fee_breakdown": _fee_summary([]),
720
  "advisor_comment": {
721
  "title": "Concentration warning",
722
  "summary": (
 
735
  ),
736
  },
737
  "allocations": [allocation],
738
+ "alternatives": alternatives,
739
  "notes": [
740
  "Single-asset portfolios receive a concentration analysis instead of a failed optimization.",
741
  "Add at least two priced assets to enable portfolio rebalancing advice.",
 
745
  _optimize_sync,
746
  [asset.__dict__ for asset in assets],
747
  simulations,
748
+ risk_profile,
749
+ risk_score,
750
+ alternatives,
751
  )
752
 
753
 
 
755
  task_id: int,
756
  portfolio_id: int,
757
  simulations: int = DEFAULT_SIMULATIONS,
758
+ risk_profile: str | None = None,
759
+ risk_score: int | None = None,
760
  ) -> None:
761
  await ImportTask.filter(id=task_id).update(
762
  status=TaskStatus.RUNNING,
763
  details={
764
  "portfolio_id": portfolio_id,
765
+ "risk_profile": _risk_settings(risk_profile, risk_score)["key"],
766
+ "risk_score": _risk_settings(risk_profile, risk_score)["risk_score"],
767
  "status_message": "Portfolio growth allocation analysis is running",
768
  },
769
  )
770
  try:
771
+ result = await analyze_portfolio_growth_allocation(portfolio_id, simulations, risk_profile, risk_score)
772
  except Exception as exc:
773
  await ImportTask.filter(id=task_id).update(
774
  status=TaskStatus.FAILED,
App/analysis/volatility.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from dataclasses import asdict, dataclass
3
+ from datetime import date, timedelta
4
+ from statistics import pstdev
5
+ from typing import Iterable
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class VolatilityResult:
10
+ annualized_volatility: float
11
+ observations: int
12
+ return_observations: int
13
+ start_date: str
14
+ end_date: str
15
+ years_covered: float
16
+ requested_years: int
17
+ annualization_periods: float
18
+ used_partial_history: bool
19
+
20
+ def to_dict(self) -> dict:
21
+ return asdict(self)
22
+
23
+
24
+ def calculate_annualized_volatility(
25
+ values: Iterable[tuple[date, float]],
26
+ *,
27
+ requested_years: int = 5,
28
+ ) -> VolatilityResult | None:
29
+ """Calculate annualized volatility from the available history up to requested_years.
30
+
31
+ The annualization factor is inferred from the average calendar gap between
32
+ observations, so sparse fund NAV histories do not get treated like daily data.
33
+ """
34
+ ordered = sorted((d, float(v)) for d, v in values if d and v and float(v) > 0)
35
+ if len(ordered) < 3:
36
+ return None
37
+
38
+ latest_date = ordered[-1][0]
39
+ cutoff = latest_date - timedelta(days=round(365.25 * requested_years))
40
+ window = [(d, v) for d, v in ordered if d >= cutoff]
41
+ if len(window) < 3:
42
+ window = ordered
43
+
44
+ log_returns = [
45
+ math.log(current / previous)
46
+ for (_, previous), (_, current) in zip(window, window[1:])
47
+ if previous > 0 and current > 0
48
+ ]
49
+ if len(log_returns) < 2:
50
+ return None
51
+
52
+ start_date = window[0][0]
53
+ end_date = window[-1][0]
54
+ span_days = max((end_date - start_date).days, 1)
55
+ average_gap_days = span_days / max(len(window) - 1, 1)
56
+ annualization_periods = 365.25 / max(average_gap_days, 1 / 365.25)
57
+ annualized = pstdev(log_returns) * math.sqrt(annualization_periods)
58
+
59
+ return VolatilityResult(
60
+ annualized_volatility=annualized,
61
+ observations=len(window),
62
+ return_observations=len(log_returns),
63
+ start_date=start_date.isoformat(),
64
+ end_date=end_date.isoformat(),
65
+ years_covered=span_days / 365.25,
66
+ requested_years=requested_years,
67
+ annualization_periods=annualization_periods,
68
+ used_partial_history=(span_days / 365.25) < requested_years * 0.95,
69
+ )
App/routers/funds/routes.py CHANGED
@@ -3,6 +3,7 @@ from datetime import date, timedelta
3
  from typing import List, Optional
4
 
5
  from App.schemas import ResponseModel, AppException
 
6
  from .models import FundManager, MutualFund, FundPerformance, FundInfo
7
  from .seed import load_fund_seed_data, sync_fund_info_from_json
8
  from App.routers.users.utils import get_current_user
@@ -84,6 +85,25 @@ def _get_fund_info(fund_name: str) -> dict | None:
84
  return None
85
 
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  # ── LIST ALL FUNDS (with latest NAV) ────────────────────────────────────────
88
 
89
  @router.get("", response_model=ResponseModel)
@@ -101,6 +121,7 @@ async def list_funds():
101
  for f in funds:
102
  latest = await FundPerformance.filter(fund_id=f.id).order_by("-record_date").first()
103
  static_info = await _serialize_fund_info(f)
 
104
  fund_list.append({
105
  "id": f.id,
106
  "name": f.name,
@@ -118,6 +139,10 @@ async def list_funds():
118
  "min_additional": f.min_additional,
119
  "benchmark": f.benchmark,
120
  "risk_level": static_info.get("risk_level") if static_info else None,
 
 
 
 
121
  "income_options": static_info.get("income_options", []) if static_info else [],
122
  "info": static_info,
123
  })
@@ -185,6 +210,7 @@ async def get_funds_performance():
185
  week_nav = float(week_rec.nav_per_unit) if week_rec and week_rec.nav_per_unit is not None else None
186
  month_nav = float(month_rec.nav_per_unit) if month_rec and month_rec.nav_per_unit is not None else None
187
  ytd_nav = float(ytd_rec.nav_per_unit) if ytd_rec and ytd_rec.nav_per_unit is not None else None
 
188
 
189
  result.append({
190
  "id": f.id,
@@ -203,6 +229,10 @@ async def get_funds_performance():
203
  "weekly_return": pct(week_nav, curr),
204
  "monthly_return": pct(month_nav, curr),
205
  "ytd_return": pct(ytd_nav, curr),
 
 
 
 
206
  })
207
 
208
  result.sort(key=lambda x: x.get("monthly_return") or 0, reverse=True)
 
3
  from typing import List, Optional
4
 
5
  from App.schemas import ResponseModel, AppException
6
+ from App.analysis.volatility import calculate_annualized_volatility
7
  from .models import FundManager, MutualFund, FundPerformance, FundInfo
8
  from .seed import load_fund_seed_data, sync_fund_info_from_json
9
  from App.routers.users.utils import get_current_user
 
85
  return None
86
 
87
 
88
+ async def _calculate_fund_volatility(fund_id: int) -> dict | None:
89
+ rows = await FundPerformance.filter(
90
+ fund_id=fund_id,
91
+ record_date__gte=date.today() - timedelta(days=365 * 5 + 7),
92
+ nav_per_unit__isnull=False,
93
+ ).order_by("record_date").values("record_date", "nav_per_unit")
94
+ result = calculate_annualized_volatility(
95
+ [(row["record_date"], float(row["nav_per_unit"])) for row in rows]
96
+ )
97
+ if result is None:
98
+ return None
99
+ payload = result.to_dict()
100
+ payload["annualized_volatility"] = round(payload["annualized_volatility"] * 100, 2)
101
+ payload["volatility_decimal"] = round(result.annualized_volatility, 6)
102
+ payload["years_covered"] = round(payload["years_covered"], 2)
103
+ payload["annualization_periods"] = round(payload["annualization_periods"], 2)
104
+ return payload
105
+
106
+
107
  # ── LIST ALL FUNDS (with latest NAV) ────────────────────────────────────────
108
 
109
  @router.get("", response_model=ResponseModel)
 
121
  for f in funds:
122
  latest = await FundPerformance.filter(fund_id=f.id).order_by("-record_date").first()
123
  static_info = await _serialize_fund_info(f)
124
+ volatility = await _calculate_fund_volatility(f.id)
125
  fund_list.append({
126
  "id": f.id,
127
  "name": f.name,
 
139
  "min_additional": f.min_additional,
140
  "benchmark": f.benchmark,
141
  "risk_level": static_info.get("risk_level") if static_info else None,
142
+ "five_year_volatility": volatility["annualized_volatility"] if volatility else None,
143
+ "annualized_volatility": volatility["annualized_volatility"] if volatility else None,
144
+ "volatility_decimal": volatility["volatility_decimal"] if volatility else None,
145
+ "volatility_window": volatility,
146
  "income_options": static_info.get("income_options", []) if static_info else [],
147
  "info": static_info,
148
  })
 
210
  week_nav = float(week_rec.nav_per_unit) if week_rec and week_rec.nav_per_unit is not None else None
211
  month_nav = float(month_rec.nav_per_unit) if month_rec and month_rec.nav_per_unit is not None else None
212
  ytd_nav = float(ytd_rec.nav_per_unit) if ytd_rec and ytd_rec.nav_per_unit is not None else None
213
+ volatility = await _calculate_fund_volatility(f.id)
214
 
215
  result.append({
216
  "id": f.id,
 
229
  "weekly_return": pct(week_nav, curr),
230
  "monthly_return": pct(month_nav, curr),
231
  "ytd_return": pct(ytd_nav, curr),
232
+ "five_year_volatility": volatility["annualized_volatility"] if volatility else None,
233
+ "annualized_volatility": volatility["annualized_volatility"] if volatility else None,
234
+ "volatility_decimal": volatility["volatility_decimal"] if volatility else None,
235
+ "volatility_window": volatility,
236
  })
237
 
238
  result.sort(key=lambda x: x.get("monthly_return") or 0, reverse=True)
App/routers/portfolio/routes.py CHANGED
@@ -36,6 +36,7 @@ from .models import (
36
  from .schemas import (
37
  PortfolioCreate,
38
  PortfolioAdvisorChatRequest,
 
39
  PortfolioUpdate,
40
  StockHoldingCreate,
41
  StockHoldingUpdate,
@@ -458,10 +459,16 @@ async def get_portfolio_summary(
458
  async def start_growth_allocation_analysis(
459
  portfolio_id: int,
460
  background_tasks: BackgroundTasks,
 
461
  simulations: int = Query(6000, ge=1000, le=50000),
 
 
462
  current_user=Depends(get_current_user),
463
  ):
464
  await _verify_ownership(portfolio_id, current_user)
 
 
 
465
 
466
  candidate_tasks = await ImportTask.filter(
467
  task_type="portfolio_growth_analysis",
@@ -473,6 +480,8 @@ async def start_growth_allocation_analysis(
473
  for task in candidate_tasks
474
  if isinstance(task.details, dict)
475
  and task.details.get("portfolio_id") == portfolio_id
 
 
476
  ),
477
  None,
478
  )
@@ -492,7 +501,9 @@ async def start_growth_allocation_analysis(
492
  status=TaskStatus.PENDING,
493
  details={
494
  "portfolio_id": portfolio_id,
495
- "simulations": simulations,
 
 
496
  "status_message": "Portfolio growth allocation analysis has been queued",
497
  },
498
  )
@@ -500,7 +511,9 @@ async def start_growth_allocation_analysis(
500
  run_portfolio_analysis_task,
501
  task.id,
502
  portfolio_id,
503
- simulations,
 
 
504
  )
505
 
506
  return ResponseModel(
 
36
  from .schemas import (
37
  PortfolioCreate,
38
  PortfolioAdvisorChatRequest,
39
+ GrowthAllocationAnalysisRequest,
40
  PortfolioUpdate,
41
  StockHoldingCreate,
42
  StockHoldingUpdate,
 
459
  async def start_growth_allocation_analysis(
460
  portfolio_id: int,
461
  background_tasks: BackgroundTasks,
462
+ payload: GrowthAllocationAnalysisRequest | None = None,
463
  simulations: int = Query(6000, ge=1000, le=50000),
464
+ risk_profile: str = Query("balanced"),
465
+ risk_score: Optional[int] = Query(None, ge=0, le=100),
466
  current_user=Depends(get_current_user),
467
  ):
468
  await _verify_ownership(portfolio_id, current_user)
469
+ requested_simulations = payload.simulations if payload else simulations
470
+ requested_risk_profile = payload.risk_profile if payload else risk_profile
471
+ requested_risk_score = payload.risk_score if payload and payload.risk_score is not None else risk_score
472
 
473
  candidate_tasks = await ImportTask.filter(
474
  task_type="portfolio_growth_analysis",
 
480
  for task in candidate_tasks
481
  if isinstance(task.details, dict)
482
  and task.details.get("portfolio_id") == portfolio_id
483
+ and task.details.get("risk_profile", "balanced") == requested_risk_profile
484
+ and task.details.get("risk_score") == requested_risk_score
485
  ),
486
  None,
487
  )
 
501
  status=TaskStatus.PENDING,
502
  details={
503
  "portfolio_id": portfolio_id,
504
+ "simulations": requested_simulations,
505
+ "risk_profile": requested_risk_profile,
506
+ "risk_score": requested_risk_score,
507
  "status_message": "Portfolio growth allocation analysis has been queued",
508
  },
509
  )
 
511
  run_portfolio_analysis_task,
512
  task.id,
513
  portfolio_id,
514
+ requested_simulations,
515
+ requested_risk_profile,
516
+ requested_risk_score,
517
  )
518
 
519
  return ResponseModel(
App/routers/portfolio/schemas.py CHANGED
@@ -43,6 +43,12 @@ class PortfolioAdvisorChatRequest(BaseModel):
43
  hypothetical_portfolio: Optional[Dict[str, Any]] = None
44
 
45
 
 
 
 
 
 
 
46
  # ──────────────── STOCK HOLDINGS ────────────────
47
 
48
 
 
43
  hypothetical_portfolio: Optional[Dict[str, Any]] = None
44
 
45
 
46
+ class GrowthAllocationAnalysisRequest(BaseModel):
47
+ simulations: int = Field(6000, ge=1000, le=50000)
48
+ risk_profile: str = Field("balanced", pattern="^(conservative|balanced|growth|low|safe|safer|defensive|high|aggressive)$")
49
+ risk_score: Optional[int] = Field(None, ge=0, le=100)
50
+
51
+
52
  # ──────────────── STOCK HOLDINGS ────────────────
53
 
54
 
App/routers/stocks/metrics.py CHANGED
@@ -2,6 +2,8 @@ from datetime import date, timedelta
2
  from decimal import Decimal
3
  from statistics import mean, pstdev
4
 
 
 
5
  from .models import Dividend, StockPriceData, StockProfile
6
 
7
 
@@ -106,6 +108,14 @@ async def calculate_metrics(stock):
106
  if len(closing_prices) > 1
107
  else None
108
  )
 
 
 
 
 
 
 
 
109
 
110
  # Fundamental metrics need processed statements; keep explicit nulls instead of fake ratios.
111
  eps = None
@@ -130,6 +140,22 @@ async def calculate_metrics(stock):
130
  else None,
131
  "return_percentage": _round(return_pct),
132
  "volatility": _round(volatility),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  "average_market_cap": round(mean(market_caps)) if market_caps else None,
134
  "dividend_per_share": _round(dividend_per_share),
135
  "dividend_yield": _round(dividend_yield),
 
2
  from decimal import Decimal
3
  from statistics import mean, pstdev
4
 
5
+ from App.analysis.volatility import calculate_annualized_volatility
6
+
7
  from .models import Dividend, StockPriceData, StockProfile
8
 
9
 
 
108
  if len(closing_prices) > 1
109
  else None
110
  )
111
+ volatility_result = calculate_annualized_volatility(
112
+ [
113
+ (row["date"], float(row["closing_price"]))
114
+ for row in all_rows
115
+ if row.get("closing_price") is not None
116
+ ]
117
+ )
118
+ volatility_payload = volatility_result.to_dict() if volatility_result else None
119
 
120
  # Fundamental metrics need processed statements; keep explicit nulls instead of fake ratios.
121
  eps = None
 
140
  else None,
141
  "return_percentage": _round(return_pct),
142
  "volatility": _round(volatility),
143
+ "five_year_volatility": (
144
+ _round(volatility_result.annualized_volatility * 100)
145
+ if volatility_result
146
+ else None
147
+ ),
148
+ "annualized_volatility": (
149
+ _round(volatility_result.annualized_volatility * 100)
150
+ if volatility_result
151
+ else None
152
+ ),
153
+ "volatility_decimal": (
154
+ round(volatility_result.annualized_volatility, 6)
155
+ if volatility_result
156
+ else None
157
+ ),
158
+ "volatility_window": volatility_payload,
159
  "average_market_cap": round(mean(market_caps)) if market_caps else None,
160
  "dividend_per_share": _round(dividend_per_share),
161
  "dividend_yield": _round(dividend_yield),