Spaces:
Sleeping
Sleeping
Protect high coupon bond income streams
Browse files
App/analysis/portfolio_optimizer.py
CHANGED
|
@@ -51,6 +51,10 @@ RISK_PROFILES = {
|
|
| 51 |
}
|
| 52 |
DEFAULT_RISK_FREE_RATE = 0.12
|
| 53 |
DEFAULT_SIMULATIONS = 6000
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
@dataclass
|
|
@@ -135,6 +139,13 @@ def _annualized_return(values: list[tuple[date, float]]) -> float | None:
|
|
| 135 |
return (1 + total_return) ** (365 / days) - 1
|
| 136 |
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
def _stock_fee_band(consideration: float) -> dict[str, Any] | None:
|
| 139 |
payload = load_dse_transaction_fee_seed_data()
|
| 140 |
for band in payload.get("bands", []):
|
|
@@ -334,6 +345,7 @@ async def _build_stock_asset(position: dict[str, Any]) -> OptimizerAsset | None:
|
|
| 334 |
capital_return = _annualized_return(price_values)
|
| 335 |
dividend_yield = _safe_float(metrics.get("dividend_yield")) / 100
|
| 336 |
liquidity = metrics.get("liquidity") or await calculate_liquidity_metrics(stock)
|
|
|
|
| 337 |
fundamentals = {
|
| 338 |
"eps": metrics.get("eps"),
|
| 339 |
"pe_ratio": metrics.get("pe_ratio"),
|
|
@@ -433,6 +445,7 @@ async def _build_fund_asset(position: dict[str, Any]) -> OptimizerAsset | None:
|
|
| 433 |
liquidity={
|
| 434 |
**prospectus_terms,
|
| 435 |
"liquidity_score": round(max(liquidity_score, 20), 2),
|
|
|
|
| 436 |
},
|
| 437 |
)
|
| 438 |
|
|
@@ -479,6 +492,11 @@ async def _build_bond_asset(position: dict[str, Any]) -> OptimizerAsset | None:
|
|
| 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"),
|
|
@@ -1065,7 +1083,7 @@ def _bond_execution_note(asset: OptimizerAsset) -> str:
|
|
| 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)
|
| 1069 |
f"{_safe_float(top.get('implied_yield_percent')):.2f}% at price "
|
| 1070 |
f"{_safe_float(top.get('price_per_100')):.2f}"
|
| 1071 |
)
|
|
@@ -1098,6 +1116,138 @@ def _bond_return_worth_note(asset: OptimizerAsset) -> str:
|
|
| 1098 |
)
|
| 1099 |
|
| 1100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1101 |
def _optimize_sync(
|
| 1102 |
assets_payload: list[dict[str, Any]],
|
| 1103 |
simulations: int,
|
|
@@ -1147,6 +1297,13 @@ def _optimize_sync(
|
|
| 1147 |
total_value,
|
| 1148 |
settings,
|
| 1149 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1150 |
liquidity_warnings.extend(
|
| 1151 |
_liquidity_trade_warnings(current_weights, best_weights, assets, total_value)
|
| 1152 |
)
|
|
@@ -1177,6 +1334,11 @@ def _optimize_sync(
|
|
| 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 |
{
|
|
|
|
| 51 |
}
|
| 52 |
DEFAULT_RISK_FREE_RATE = 0.12
|
| 53 |
DEFAULT_SIMULATIONS = 6000
|
| 54 |
+
HIGH_COUPON_ADVANTAGE_BPS = 150
|
| 55 |
+
LEGACY_BOND_REPLACEMENT_MARGIN = 0.06
|
| 56 |
+
LOW_MODERATE_VOLATILITY_MAX = 0.16
|
| 57 |
+
MIN_REPLACEMENT_HISTORY_YEARS = 3.0
|
| 58 |
|
| 59 |
|
| 60 |
@dataclass
|
|
|
|
| 139 |
return (1 + total_return) ** (365 / days) - 1
|
| 140 |
|
| 141 |
|
| 142 |
+
def _history_years(values: list[tuple[date, float]]) -> float:
|
| 143 |
+
ordered = [item for item in sorted(values, key=lambda item: item[0]) if item[1] > 0]
|
| 144 |
+
if len(ordered) < 2:
|
| 145 |
+
return 0.0
|
| 146 |
+
return max((ordered[-1][0] - ordered[0][0]).days / 365.25, 0.0)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
def _stock_fee_band(consideration: float) -> dict[str, Any] | None:
|
| 150 |
payload = load_dse_transaction_fee_seed_data()
|
| 151 |
for band in payload.get("bands", []):
|
|
|
|
| 345 |
capital_return = _annualized_return(price_values)
|
| 346 |
dividend_yield = _safe_float(metrics.get("dividend_yield")) / 100
|
| 347 |
liquidity = metrics.get("liquidity") or await calculate_liquidity_metrics(stock)
|
| 348 |
+
liquidity["history_years"] = round(_history_years(price_values), 2)
|
| 349 |
fundamentals = {
|
| 350 |
"eps": metrics.get("eps"),
|
| 351 |
"pe_ratio": metrics.get("pe_ratio"),
|
|
|
|
| 445 |
liquidity={
|
| 446 |
**prospectus_terms,
|
| 447 |
"liquidity_score": round(max(liquidity_score, 20), 2),
|
| 448 |
+
"history_years": round(_history_years(nav_values), 2),
|
| 449 |
},
|
| 450 |
)
|
| 451 |
|
|
|
|
| 492 |
"latest_yield_percent": latest_yield_percent,
|
| 493 |
"coupon_rate_percent": _safe_float(bond.coupon_rate),
|
| 494 |
"price_percent": price_per_100,
|
| 495 |
+
"primary_reinvestment_yield_percent": (
|
| 496 |
+
_safe_float((pricing_context.get("comparable_primary_market") or [{}])[0].get("implied_yield_percent"))
|
| 497 |
+
if pricing_context.get("comparable_primary_market")
|
| 498 |
+
else None
|
| 499 |
+
),
|
| 500 |
"same_bond_secondary_available": pricing_context.get("same_bond_secondary_available"),
|
| 501 |
"comparable_secondary_market": pricing_context.get("comparable_secondary_market") or [],
|
| 502 |
"comparable_median_yield_percent": pricing_context.get("comparable_median_yield_percent"),
|
|
|
|
| 1083 |
parts.append(
|
| 1084 |
f"recent primary auction reference {top.get('bond_no')} "
|
| 1085 |
f"from {top.get('auction_date')} "
|
| 1086 |
+
f"({top.get('maturity_years')}y) approximate reinvestment yield "
|
| 1087 |
f"{_safe_float(top.get('implied_yield_percent')):.2f}% at price "
|
| 1088 |
f"{_safe_float(top.get('price_per_100')):.2f}"
|
| 1089 |
)
|
|
|
|
| 1116 |
)
|
| 1117 |
|
| 1118 |
|
| 1119 |
+
def _bond_coupon_advantage(asset: OptimizerAsset) -> float:
|
| 1120 |
+
liquidity = asset.liquidity or {}
|
| 1121 |
+
coupon = _safe_float(liquidity.get("coupon_rate_percent")) / 100
|
| 1122 |
+
reinvestment_yield = _safe_float(liquidity.get("primary_reinvestment_yield_percent")) / 100
|
| 1123 |
+
if coupon <= 0 or reinvestment_yield <= 0:
|
| 1124 |
+
return 0.0
|
| 1125 |
+
return coupon - reinvestment_yield
|
| 1126 |
+
|
| 1127 |
+
|
| 1128 |
+
def _is_irreplaceable_income_bond(asset: OptimizerAsset) -> bool:
|
| 1129 |
+
return (
|
| 1130 |
+
asset.asset_type == "BOND"
|
| 1131 |
+
and _bond_coupon_advantage(asset) >= HIGH_COUPON_ADVANTAGE_BPS / 10_000
|
| 1132 |
+
)
|
| 1133 |
+
|
| 1134 |
+
|
| 1135 |
+
def _has_rock_solid_fundamentals(asset: OptimizerAsset) -> bool:
|
| 1136 |
+
fundamentals = asset.fundamentals or {}
|
| 1137 |
+
if asset.asset_type != "STOCK":
|
| 1138 |
+
return False
|
| 1139 |
+
pe_ratio = _safe_float(fundamentals.get("pe_ratio"))
|
| 1140 |
+
pb_ratio = _safe_float(fundamentals.get("pb_ratio"))
|
| 1141 |
+
eps = _safe_float(fundamentals.get("eps"))
|
| 1142 |
+
debt_to_equity = _safe_float(fundamentals.get("debt_to_equity"), default=-1)
|
| 1143 |
+
return (
|
| 1144 |
+
eps > 0
|
| 1145 |
+
and 0 < pe_ratio <= 22
|
| 1146 |
+
and 0 < pb_ratio <= 3.5
|
| 1147 |
+
and 0 <= debt_to_equity <= 150
|
| 1148 |
+
)
|
| 1149 |
+
|
| 1150 |
+
|
| 1151 |
+
def _has_visible_macro_tailwind(asset: OptimizerAsset) -> bool:
|
| 1152 |
+
data = {**(asset.fundamentals or {}), **(asset.liquidity or {})}
|
| 1153 |
+
return bool(
|
| 1154 |
+
data.get("macro_tailwind")
|
| 1155 |
+
or data.get("sector_tailwind")
|
| 1156 |
+
or data.get("rate_tailwind")
|
| 1157 |
+
or str(data.get("macro_context") or "").lower() in {"tailwind", "supportive", "positive"}
|
| 1158 |
+
)
|
| 1159 |
+
|
| 1160 |
+
|
| 1161 |
+
def _clean_exit_liquidity(asset: OptimizerAsset, trade_value: float) -> bool:
|
| 1162 |
+
liquidity = asset.liquidity or {}
|
| 1163 |
+
if asset.asset_type == "STOCK":
|
| 1164 |
+
max_buy_value = _safe_float(liquidity.get("max_buy_value_20d"))
|
| 1165 |
+
return max_buy_value > 0 and trade_value <= max_buy_value
|
| 1166 |
+
if asset.asset_type == "FUND":
|
| 1167 |
+
redemption_days = _safe_float(liquidity.get("redemption_days"), default=999)
|
| 1168 |
+
return redemption_days <= 7 and bool(liquidity.get("prospectus_backed"))
|
| 1169 |
+
return False
|
| 1170 |
+
|
| 1171 |
+
|
| 1172 |
+
def _replacement_quality_for_irreplaceable_bond(
|
| 1173 |
+
bond: OptimizerAsset,
|
| 1174 |
+
assets: list[OptimizerAsset],
|
| 1175 |
+
suggested_weights: np.ndarray,
|
| 1176 |
+
current_weights: np.ndarray,
|
| 1177 |
+
total_value: float,
|
| 1178 |
+
) -> tuple[bool, list[str]]:
|
| 1179 |
+
coupon_advantage = _bond_coupon_advantage(bond)
|
| 1180 |
+
required_excess = coupon_advantage + LEGACY_BOND_REPLACEMENT_MARGIN
|
| 1181 |
+
increased_assets = [
|
| 1182 |
+
(asset, float(suggested - current) * total_value)
|
| 1183 |
+
for asset, suggested, current in zip(assets, suggested_weights, current_weights)
|
| 1184 |
+
if asset.key != bond.key and suggested > current + 0.01
|
| 1185 |
+
]
|
| 1186 |
+
failures: list[str] = []
|
| 1187 |
+
for asset, trade_value in increased_assets:
|
| 1188 |
+
net_return_advantage = asset.expected_return - asset.fee_rate - bond.income_yield
|
| 1189 |
+
gates = {
|
| 1190 |
+
"return_margin": net_return_advantage >= required_excess,
|
| 1191 |
+
"low_moderate_volatility": asset.volatility <= LOW_MODERATE_VOLATILITY_MAX,
|
| 1192 |
+
"rock_solid_fundamentals": _has_rock_solid_fundamentals(asset),
|
| 1193 |
+
"macro_tailwind": _has_visible_macro_tailwind(asset),
|
| 1194 |
+
"history_and_liquidity": (
|
| 1195 |
+
_safe_float((asset.liquidity or {}).get("history_years")) >= MIN_REPLACEMENT_HISTORY_YEARS
|
| 1196 |
+
and _clean_exit_liquidity(asset, trade_value)
|
| 1197 |
+
),
|
| 1198 |
+
}
|
| 1199 |
+
if all(gates.values()):
|
| 1200 |
+
return True, []
|
| 1201 |
+
failed = ", ".join(name for name, passed in gates.items() if not passed)
|
| 1202 |
+
failures.append(f"{asset.symbol} failed replacement gates: {failed}")
|
| 1203 |
+
if not failures:
|
| 1204 |
+
failures.append("No increased replacement asset was available for the income stream.")
|
| 1205 |
+
return False, failures
|
| 1206 |
+
|
| 1207 |
+
|
| 1208 |
+
def _protect_irreplaceable_bonds(
|
| 1209 |
+
current_weights: np.ndarray,
|
| 1210 |
+
suggested_weights: np.ndarray,
|
| 1211 |
+
assets: list[OptimizerAsset],
|
| 1212 |
+
total_value: float,
|
| 1213 |
+
) -> tuple[np.ndarray, list[str]]:
|
| 1214 |
+
protected = suggested_weights.copy()
|
| 1215 |
+
warnings: list[str] = []
|
| 1216 |
+
for index, asset in enumerate(assets):
|
| 1217 |
+
if not _is_irreplaceable_income_bond(asset) or protected[index] >= current_weights[index]:
|
| 1218 |
+
continue
|
| 1219 |
+
qualified, failures = _replacement_quality_for_irreplaceable_bond(
|
| 1220 |
+
asset,
|
| 1221 |
+
assets,
|
| 1222 |
+
protected,
|
| 1223 |
+
current_weights,
|
| 1224 |
+
total_value,
|
| 1225 |
+
)
|
| 1226 |
+
if qualified:
|
| 1227 |
+
continue
|
| 1228 |
+
restored = float(current_weights[index] - protected[index])
|
| 1229 |
+
protected[index] = current_weights[index]
|
| 1230 |
+
warnings.append(
|
| 1231 |
+
f"{asset.symbol} reduction blocked: coupon materially exceeds recent primary reinvestment yield, so default is hold to maturity unless all replacement gates pass. "
|
| 1232 |
+
+ " ".join(failures[:2])
|
| 1233 |
+
)
|
| 1234 |
+
receiver_indices = [
|
| 1235 |
+
i
|
| 1236 |
+
for i, (current, suggested) in enumerate(zip(current_weights, protected))
|
| 1237 |
+
if i != index and suggested > current
|
| 1238 |
+
]
|
| 1239 |
+
receiver_total = sum(float(protected[i] - current_weights[i]) for i in receiver_indices)
|
| 1240 |
+
if receiver_total > 0:
|
| 1241 |
+
for i in receiver_indices:
|
| 1242 |
+
reduction = restored * float(protected[i] - current_weights[i]) / receiver_total
|
| 1243 |
+
protected[i] = max(current_weights[i], protected[i] - reduction)
|
| 1244 |
+
|
| 1245 |
+
total = float(protected.sum())
|
| 1246 |
+
if total > 0:
|
| 1247 |
+
protected = protected / total
|
| 1248 |
+
return protected, warnings
|
| 1249 |
+
|
| 1250 |
+
|
| 1251 |
def _optimize_sync(
|
| 1252 |
assets_payload: list[dict[str, Any]],
|
| 1253 |
simulations: int,
|
|
|
|
| 1297 |
total_value,
|
| 1298 |
settings,
|
| 1299 |
)
|
| 1300 |
+
best_weights, bond_protection_warnings = _protect_irreplaceable_bonds(
|
| 1301 |
+
current_weights,
|
| 1302 |
+
best_weights,
|
| 1303 |
+
assets,
|
| 1304 |
+
total_value,
|
| 1305 |
+
)
|
| 1306 |
+
liquidity_warnings.extend(bond_protection_warnings)
|
| 1307 |
liquidity_warnings.extend(
|
| 1308 |
_liquidity_trade_warnings(current_weights, best_weights, assets, total_value)
|
| 1309 |
)
|
|
|
|
| 1334 |
if asset.asset_type == "BOND":
|
| 1335 |
reason = f"{reason}. {_bond_return_worth_note(asset)} {_bond_execution_note(asset)}"
|
| 1336 |
elif asset.asset_type == "BOND":
|
| 1337 |
+
if _is_irreplaceable_income_bond(asset):
|
| 1338 |
+
reason = (
|
| 1339 |
+
"Hold to maturity by default: coupon materially exceeds recent primary reinvestment yields, "
|
| 1340 |
+
"and no replacement passed all return, volatility, fundamentals, macro, history, and liquidity gates"
|
| 1341 |
+
)
|
| 1342 |
reason = f"{reason}. {_bond_return_worth_note(asset)} {_bond_execution_note(asset)}"
|
| 1343 |
allocations.append(
|
| 1344 |
{
|
App/routers/economy/valuation.py
CHANGED
|
@@ -92,13 +92,22 @@ async def get_comparable_primary_bonds(
|
|
| 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
|
| 96 |
try:
|
| 97 |
-
|
| 98 |
except Exception:
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
years_gap = abs((years or 0) - (target_years or years or 0))
|
| 101 |
-
coupon_gap = abs(
|
| 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 |
|
|
@@ -113,7 +122,7 @@ async def get_comparable_primary_bonds(
|
|
| 113 |
"maturity_date": row.maturity_date.isoformat(),
|
| 114 |
"coupon_rate": row.coupon_rate,
|
| 115 |
"price_per_100": row.price_per_100,
|
| 116 |
-
"implied_yield_percent": row
|
| 117 |
"source": "primary_auction_reference",
|
| 118 |
}
|
| 119 |
for row in sorted(comparable, key=score)[:limit]
|
|
|
|
| 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 maturity_years(row: Bond) -> float:
|
| 96 |
try:
|
| 97 |
+
return float(str(row.maturity_years or "").split()[0])
|
| 98 |
except Exception:
|
| 99 |
+
return target_years if target_years is not None else 0
|
| 100 |
+
|
| 101 |
+
def approximate_yield(row: Bond) -> float:
|
| 102 |
+
years = max(maturity_years(row), 1.0)
|
| 103 |
+
coupon = float(row.coupon_rate or 0)
|
| 104 |
+
price = float(row.price_per_100 or 100)
|
| 105 |
+
return max(((coupon + ((100 - price) / years)) / ((100 + price) / 2)) * 100, 0.0)
|
| 106 |
+
|
| 107 |
+
def score(row: Bond) -> tuple[float, int, float, int]:
|
| 108 |
+
years = maturity_years(row)
|
| 109 |
years_gap = abs((years or 0) - (target_years or years or 0))
|
| 110 |
+
coupon_gap = abs(approximate_yield(row) - target_coupon)
|
| 111 |
auction_recency = -int(row.auction_date.toordinal()) if row.auction_date else 0
|
| 112 |
return (years_gap, auction_recency, coupon_gap, -int(row.auction_number or 0))
|
| 113 |
|
|
|
|
| 122 |
"maturity_date": row.maturity_date.isoformat(),
|
| 123 |
"coupon_rate": row.coupon_rate,
|
| 124 |
"price_per_100": row.price_per_100,
|
| 125 |
+
"implied_yield_percent": round(approximate_yield(row), 4),
|
| 126 |
"source": "primary_auction_reference",
|
| 127 |
}
|
| 128 |
for row in sorted(comparable, key=score)[:limit]
|
tests/test_portfolio_optimizer_fundamentals.py
CHANGED
|
@@ -7,6 +7,7 @@ from App.analysis.portfolio_optimizer import (
|
|
| 7 |
_bond_return_worth_note,
|
| 8 |
_fundamental_adjusted_stock_return,
|
| 9 |
_liquidity_capacity_penalty,
|
|
|
|
| 10 |
)
|
| 11 |
from App.routers.stocks.fundamentals import parse_market_table, parse_stock_page
|
| 12 |
|
|
@@ -140,3 +141,54 @@ def test_bond_worth_note_uses_market_yield_not_coupon():
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
_bond_return_worth_note,
|
| 8 |
_fundamental_adjusted_stock_return,
|
| 9 |
_liquidity_capacity_penalty,
|
| 10 |
+
_protect_irreplaceable_bonds,
|
| 11 |
)
|
| 12 |
from App.routers.stocks.fundamentals import parse_market_table, parse_stock_page
|
| 13 |
|
|
|
|
| 141 |
assert "market yield 9.03%" in note
|
| 142 |
assert "15.50%" not in note
|
| 143 |
assert "coupon income is not the same as total return" in note
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_irreplaceable_bond_reduction_is_blocked_without_all_replacement_gates():
|
| 147 |
+
current = np.array([0.5, 0.5])
|
| 148 |
+
suggested = np.array([0.2, 0.8])
|
| 149 |
+
bond = OptimizerAsset(
|
| 150 |
+
key="BOND:1",
|
| 151 |
+
asset_id=1,
|
| 152 |
+
asset_type="BOND",
|
| 153 |
+
symbol="TZ1996103283",
|
| 154 |
+
name="Legacy Treasury Bond",
|
| 155 |
+
quantity=1,
|
| 156 |
+
current_price=1.5,
|
| 157 |
+
current_value=500_000,
|
| 158 |
+
expected_return=0.09,
|
| 159 |
+
volatility=0.04,
|
| 160 |
+
income_yield=0.103,
|
| 161 |
+
fee_rate=0.0,
|
| 162 |
+
liquidity={
|
| 163 |
+
"coupon_rate_percent": 15.5,
|
| 164 |
+
"primary_reinvestment_yield_percent": 9.0,
|
| 165 |
+
"price_percent": 150,
|
| 166 |
+
},
|
| 167 |
+
)
|
| 168 |
+
stock = OptimizerAsset(
|
| 169 |
+
key="STOCK:1",
|
| 170 |
+
asset_id=1,
|
| 171 |
+
asset_type="STOCK",
|
| 172 |
+
symbol="SPEC",
|
| 173 |
+
name="Speculative Stock",
|
| 174 |
+
quantity=1,
|
| 175 |
+
current_price=1,
|
| 176 |
+
current_value=500_000,
|
| 177 |
+
expected_return=0.3,
|
| 178 |
+
volatility=0.28,
|
| 179 |
+
income_yield=0.0,
|
| 180 |
+
fee_rate=0.0206,
|
| 181 |
+
fundamentals={"eps": 10, "pe_ratio": 12, "pb_ratio": 2, "debt_to_equity": 50},
|
| 182 |
+
liquidity={"history_years": 4, "max_buy_value_20d": 1_000_000},
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
protected, warnings = _protect_irreplaceable_bonds(
|
| 186 |
+
current,
|
| 187 |
+
suggested,
|
| 188 |
+
[bond, stock],
|
| 189 |
+
1_000_000,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
assert protected[0] >= current[0]
|
| 193 |
+
assert protected[1] <= current[1]
|
| 194 |
+
assert warnings
|