Spaces:
Sleeping
Sleeping
Use market yields for bond allocation advice
Browse files
App/analysis/portfolio_optimizer.py
CHANGED
|
@@ -448,6 +448,14 @@ async def _build_bond_asset(position: dict[str, Any]) -> OptimizerAsset | None:
|
|
| 448 |
quantity = _safe_float(position["quantity"])
|
| 449 |
current_value = quantity * current_price
|
| 450 |
coupon_rate = _safe_float(bond.coupon_rate) / 100
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
return OptimizerAsset(
|
| 453 |
key=f"BOND:{bond.id}",
|
|
@@ -458,9 +466,9 @@ async def _build_bond_asset(position: dict[str, Any]) -> OptimizerAsset | None:
|
|
| 458 |
quantity=quantity,
|
| 459 |
current_price=current_price,
|
| 460 |
current_value=current_value,
|
| 461 |
-
expected_return=
|
| 462 |
volatility=0.04,
|
| 463 |
-
income_yield=
|
| 464 |
fee_rate=0.0,
|
| 465 |
liquidity={
|
| 466 |
"liquidity_score": pricing_context.get("liquidity_score", 45),
|
|
@@ -468,7 +476,9 @@ async def _build_bond_asset(position: dict[str, Any]) -> OptimizerAsset | None:
|
|
| 468 |
"execution_caveat": pricing_context.get("execution_caveat"),
|
| 469 |
"latest_trade_date": pricing_context.get("latest_trade_date"),
|
| 470 |
"price_source": pricing_context.get("price_source"),
|
| 471 |
-
"latest_yield_percent":
|
|
|
|
|
|
|
| 472 |
"same_bond_secondary_available": pricing_context.get("same_bond_secondary_available"),
|
| 473 |
"comparable_secondary_market": pricing_context.get("comparable_secondary_market") or [],
|
| 474 |
"comparable_median_yield_percent": pricing_context.get("comparable_median_yield_percent"),
|
|
@@ -1053,17 +1063,41 @@ def _bond_execution_note(asset: OptimizerAsset) -> str:
|
|
| 1053 |
if primary:
|
| 1054 |
top = primary[0]
|
| 1055 |
parts.append(
|
| 1056 |
-
f"
|
|
|
|
| 1057 |
f"({top.get('maturity_years')}y) coupon/yield proxy "
|
| 1058 |
f"{_safe_float(top.get('implied_yield_percent')):.2f}% at price "
|
| 1059 |
f"{_safe_float(top.get('price_per_100')):.2f}"
|
| 1060 |
)
|
|
|
|
|
|
|
| 1061 |
caveat = liquidity.get("execution_caveat")
|
| 1062 |
if caveat:
|
| 1063 |
parts.append(str(caveat))
|
| 1064 |
return "Secondary/primary market check: " + "; ".join(parts)
|
| 1065 |
|
| 1066 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1067 |
def _optimize_sync(
|
| 1068 |
assets_payload: list[dict[str, Any]],
|
| 1069 |
simulations: int,
|
|
@@ -1137,13 +1171,13 @@ def _optimize_sync(
|
|
| 1137 |
else "Increase for growth-adjusted return after fees"
|
| 1138 |
)
|
| 1139 |
if asset.asset_type == "BOND":
|
| 1140 |
-
reason = f"{reason}. {_bond_execution_note(asset)}"
|
| 1141 |
elif difference < -0.03:
|
| 1142 |
reason = "Reduce concentration or weaker risk-adjusted return"
|
| 1143 |
if asset.asset_type == "BOND":
|
| 1144 |
-
reason = f"{reason}. {_bond_execution_note(asset)}"
|
| 1145 |
elif asset.asset_type == "BOND":
|
| 1146 |
-
reason = f"{reason}. {_bond_execution_note(asset)}"
|
| 1147 |
allocations.append(
|
| 1148 |
{
|
| 1149 |
"asset_id": asset.asset_id,
|
|
|
|
| 448 |
quantity = _safe_float(position["quantity"])
|
| 449 |
current_value = quantity * current_price
|
| 450 |
coupon_rate = _safe_float(bond.coupon_rate) / 100
|
| 451 |
+
latest_yield_percent = pricing_context.get("latest_yield_percent")
|
| 452 |
+
market_yield = (
|
| 453 |
+
_safe_float(latest_yield_percent) / 100
|
| 454 |
+
if latest_yield_percent not in (None, "")
|
| 455 |
+
else 0.0
|
| 456 |
+
)
|
| 457 |
+
expected_return = market_yield if market_yield > 0 else max(coupon_rate, 0.10)
|
| 458 |
+
income_yield = coupon_rate / current_price if current_price > 0 else coupon_rate
|
| 459 |
|
| 460 |
return OptimizerAsset(
|
| 461 |
key=f"BOND:{bond.id}",
|
|
|
|
| 466 |
quantity=quantity,
|
| 467 |
current_price=current_price,
|
| 468 |
current_value=current_value,
|
| 469 |
+
expected_return=expected_return,
|
| 470 |
volatility=0.04,
|
| 471 |
+
income_yield=income_yield,
|
| 472 |
fee_rate=0.0,
|
| 473 |
liquidity={
|
| 474 |
"liquidity_score": pricing_context.get("liquidity_score", 45),
|
|
|
|
| 476 |
"execution_caveat": pricing_context.get("execution_caveat"),
|
| 477 |
"latest_trade_date": pricing_context.get("latest_trade_date"),
|
| 478 |
"price_source": pricing_context.get("price_source"),
|
| 479 |
+
"latest_yield_percent": latest_yield_percent,
|
| 480 |
+
"coupon_rate_percent": _safe_float(bond.coupon_rate),
|
| 481 |
+
"price_percent": price_per_100,
|
| 482 |
"same_bond_secondary_available": pricing_context.get("same_bond_secondary_available"),
|
| 483 |
"comparable_secondary_market": pricing_context.get("comparable_secondary_market") or [],
|
| 484 |
"comparable_median_yield_percent": pricing_context.get("comparable_median_yield_percent"),
|
|
|
|
| 1063 |
if primary:
|
| 1064 |
top = primary[0]
|
| 1065 |
parts.append(
|
| 1066 |
+
f"recent primary auction reference {top.get('bond_no')} "
|
| 1067 |
+
f"from {top.get('auction_date')} "
|
| 1068 |
f"({top.get('maturity_years')}y) coupon/yield proxy "
|
| 1069 |
f"{_safe_float(top.get('implied_yield_percent')):.2f}% at price "
|
| 1070 |
f"{_safe_float(top.get('price_per_100')):.2f}"
|
| 1071 |
)
|
| 1072 |
+
else:
|
| 1073 |
+
parts.append("no recent comparable primary auction reference is stored")
|
| 1074 |
caveat = liquidity.get("execution_caveat")
|
| 1075 |
if caveat:
|
| 1076 |
parts.append(str(caveat))
|
| 1077 |
return "Secondary/primary market check: " + "; ".join(parts)
|
| 1078 |
|
| 1079 |
|
| 1080 |
+
def _bond_return_worth_note(asset: OptimizerAsset) -> str:
|
| 1081 |
+
liquidity = asset.liquidity or {}
|
| 1082 |
+
net_return = asset.expected_return - asset.fee_rate
|
| 1083 |
+
hurdle = DEFAULT_RISK_FREE_RATE
|
| 1084 |
+
spread = net_return - hurdle
|
| 1085 |
+
price_percent = _safe_float(liquidity.get("price_percent"), asset.current_price * 100)
|
| 1086 |
+
latest_yield = _safe_float(liquidity.get("latest_yield_percent"), net_return * 100)
|
| 1087 |
+
income_yield = asset.income_yield * 100
|
| 1088 |
+
verdict = "adequate" if spread >= 0.015 else "thin" if spread >= 0 else "weak"
|
| 1089 |
+
premium_note = (
|
| 1090 |
+
f" premium price {price_percent:.2f}% means coupon income is not the same as total return;"
|
| 1091 |
+
if price_percent > 110
|
| 1092 |
+
else ""
|
| 1093 |
+
)
|
| 1094 |
+
return (
|
| 1095 |
+
f"Return worth check: {verdict}; market yield {latest_yield:.2f}% "
|
| 1096 |
+
f"vs {hurdle * 100:.2f}% hurdle ({spread * 100:+.2f}%), "
|
| 1097 |
+
f"income yield on invested price about {income_yield:.2f}%.{premium_note}"
|
| 1098 |
+
)
|
| 1099 |
+
|
| 1100 |
+
|
| 1101 |
def _optimize_sync(
|
| 1102 |
assets_payload: list[dict[str, Any]],
|
| 1103 |
simulations: int,
|
|
|
|
| 1171 |
else "Increase for growth-adjusted return after fees"
|
| 1172 |
)
|
| 1173 |
if asset.asset_type == "BOND":
|
| 1174 |
+
reason = f"{reason}. {_bond_return_worth_note(asset)} {_bond_execution_note(asset)}"
|
| 1175 |
elif difference < -0.03:
|
| 1176 |
reason = "Reduce concentration or weaker risk-adjusted return"
|
| 1177 |
if asset.asset_type == "BOND":
|
| 1178 |
+
reason = f"{reason}. {_bond_return_worth_note(asset)} {_bond_execution_note(asset)}"
|
| 1179 |
elif asset.asset_type == "BOND":
|
| 1180 |
+
reason = f"{reason}. {_bond_return_worth_note(asset)} {_bond_execution_note(asset)}"
|
| 1181 |
allocations.append(
|
| 1182 |
{
|
| 1183 |
"asset_id": asset.asset_id,
|
App/routers/economy/valuation.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from datetime import date
|
|
|
|
| 4 |
from decimal import Decimal
|
| 5 |
from statistics import median
|
| 6 |
from typing import Any
|
|
@@ -79,23 +80,27 @@ async def get_comparable_primary_bonds(
|
|
| 79 |
bond: Any,
|
| 80 |
*,
|
| 81 |
limit: int = 5,
|
|
|
|
| 82 |
) -> list[dict[str, Any]]:
|
|
|
|
|
|
|
| 83 |
target_years = None
|
| 84 |
try:
|
| 85 |
target_years = float(str(getattr(bond, "maturity_years", "")).split()[0])
|
| 86 |
except Exception:
|
| 87 |
target_years = None
|
| 88 |
target_coupon = float(getattr(bond, "coupon_rate", 0) or 0)
|
| 89 |
-
rows = await Bond.
|
| 90 |
|
| 91 |
-
def score(row: Bond) -> tuple[float, float, int]:
|
| 92 |
try:
|
| 93 |
years = float(str(row.maturity_years or "").split()[0])
|
| 94 |
except Exception:
|
| 95 |
years = target_years if target_years is not None else 0
|
| 96 |
years_gap = abs((years or 0) - (target_years or years or 0))
|
| 97 |
coupon_gap = abs(float(row.coupon_rate or 0) - target_coupon)
|
| 98 |
-
|
|
|
|
| 99 |
|
| 100 |
comparable = [row for row in rows if row.id != getattr(bond, "id", None)]
|
| 101 |
return [
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from datetime import date
|
| 4 |
+
from datetime import timedelta
|
| 5 |
from decimal import Decimal
|
| 6 |
from statistics import median
|
| 7 |
from typing import Any
|
|
|
|
| 80 |
bond: Any,
|
| 81 |
*,
|
| 82 |
limit: int = 5,
|
| 83 |
+
recent_days: int = 370,
|
| 84 |
) -> list[dict[str, Any]]:
|
| 85 |
+
target = date.today()
|
| 86 |
+
min_auction_date = target - timedelta(days=recent_days)
|
| 87 |
target_years = None
|
| 88 |
try:
|
| 89 |
target_years = float(str(getattr(bond, "maturity_years", "")).split()[0])
|
| 90 |
except Exception:
|
| 91 |
target_years = None
|
| 92 |
target_coupon = float(getattr(bond, "coupon_rate", 0) or 0)
|
| 93 |
+
rows = await Bond.filter(auction_date__gte=min_auction_date).order_by("-auction_date").limit(500)
|
| 94 |
|
| 95 |
+
def score(row: Bond) -> tuple[float, int, float, int]:
|
| 96 |
try:
|
| 97 |
years = float(str(row.maturity_years or "").split()[0])
|
| 98 |
except Exception:
|
| 99 |
years = target_years if target_years is not None else 0
|
| 100 |
years_gap = abs((years or 0) - (target_years or years or 0))
|
| 101 |
coupon_gap = abs(float(row.coupon_rate or 0) - target_coupon)
|
| 102 |
+
auction_recency = -int(row.auction_date.toordinal()) if row.auction_date else 0
|
| 103 |
+
return (years_gap, auction_recency, coupon_gap, -int(row.auction_number or 0))
|
| 104 |
|
| 105 |
comparable = [row for row in rows if row.id != getattr(bond, "id", None)]
|
| 106 |
return [
|
tests/test_portfolio_optimizer_fundamentals.py
CHANGED
|
@@ -4,6 +4,7 @@ from decimal import Decimal
|
|
| 4 |
from App.analysis.portfolio_optimizer import (
|
| 5 |
OptimizerAsset,
|
| 6 |
_apply_liquidity_caps,
|
|
|
|
| 7 |
_fundamental_adjusted_stock_return,
|
| 8 |
_liquidity_capacity_penalty,
|
| 9 |
)
|
|
@@ -110,3 +111,32 @@ def test_liquidity_capacity_penalty_and_cap_reduce_illiquid_stock_buy():
|
|
| 110 |
assert penalty > 0
|
| 111 |
assert capped[0] <= 0.25
|
| 112 |
assert warnings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from App.analysis.portfolio_optimizer import (
|
| 5 |
OptimizerAsset,
|
| 6 |
_apply_liquidity_caps,
|
| 7 |
+
_bond_return_worth_note,
|
| 8 |
_fundamental_adjusted_stock_return,
|
| 9 |
_liquidity_capacity_penalty,
|
| 10 |
)
|
|
|
|
| 111 |
assert penalty > 0
|
| 112 |
assert capped[0] <= 0.25
|
| 113 |
assert warnings
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_bond_worth_note_uses_market_yield_not_coupon():
|
| 117 |
+
asset = OptimizerAsset(
|
| 118 |
+
key="BOND:1",
|
| 119 |
+
asset_id=1,
|
| 120 |
+
asset_type="BOND",
|
| 121 |
+
symbol="TZ1996103283",
|
| 122 |
+
name="20 Yr Treasury Bond",
|
| 123 |
+
quantity=1,
|
| 124 |
+
current_price=1.518367,
|
| 125 |
+
current_value=151.8367,
|
| 126 |
+
expected_return=0.0903,
|
| 127 |
+
volatility=0.04,
|
| 128 |
+
income_yield=0.155 / 1.518367,
|
| 129 |
+
fee_rate=0.0,
|
| 130 |
+
liquidity={
|
| 131 |
+
"price_percent": 151.8367,
|
| 132 |
+
"latest_yield_percent": 9.03,
|
| 133 |
+
"coupon_rate_percent": 15.5,
|
| 134 |
+
},
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
note = _bond_return_worth_note(asset)
|
| 138 |
+
|
| 139 |
+
assert "weak" in note
|
| 140 |
+
assert "market yield 9.03%" in note
|
| 141 |
+
assert "15.50%" not in note
|
| 142 |
+
assert "coupon income is not the same as total return" in note
|