Spaces:
Sleeping
Sleeping
Add risk-adjusted volatility allocation analysis
Browse files- App/analysis/portfolio_optimizer.py +288 -32
- App/analysis/volatility.py +69 -0
- App/routers/funds/routes.py +30 -0
- App/routers/portfolio/routes.py +15 -2
- App/routers/portfolio/schemas.py +6 -0
- App/routers/stocks/metrics.py +26 -0
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 *
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 *
|
| 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 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
for current, suggested, asset in zip(current_weights, suggested_weights, assets):
|
| 273 |
increase = max(0.0, float(suggested - current)) * total_value
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 404 |
-
"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 |
-
"
|
| 419 |
"Stock buy fee estimates use the stored DSE transaction fee bands.",
|
| 420 |
-
"
|
| 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":
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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),
|