225 lines
9.0 KiB
Python
225 lines
9.0 KiB
Python
"""Walk-forward validation for the analysis-only dynamic allocation strategy."""
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, date, datetime
|
|
from decimal import ROUND_HALF_UP, Decimal
|
|
from uuid import uuid4
|
|
|
|
from app.model.advisor import AllocationBacktestRun
|
|
from app.service.dynamic_allocation_optimizer import (
|
|
AssetClassMarketMetric,
|
|
DynamicAllocationOptimizer,
|
|
)
|
|
from app.service.product_metric_service import ProductMetricService
|
|
|
|
_HUNDRED = Decimal("100")
|
|
_ASSET_CLASSES = ("cash_management_etf", "bond_etf", "equity_etf")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class BacktestPrice:
|
|
trade_date: date
|
|
close_price: Decimal
|
|
turnover_amount: Decimal | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AllocationBacktestResult:
|
|
status: str
|
|
started_on: date | None
|
|
ended_on: date | None
|
|
observation_count: int
|
|
static_total_return_pct: Decimal | None
|
|
dynamic_total_return_pct: Decimal | None
|
|
static_max_drawdown_pct: Decimal | None
|
|
dynamic_max_drawdown_pct: Decimal | None
|
|
dynamic_rebalance_count: int
|
|
liquidity_history_coverage_pct: Decimal
|
|
limitations: tuple[str, ...]
|
|
|
|
|
|
class AllocationBacktestService:
|
|
"""Runs a no-lookahead, periodic-rebalance comparison against a static baseline."""
|
|
|
|
STRATEGY_VERSION = "dynamic_allocation_walk_forward_v1"
|
|
LOOKBACK_DAYS = 120
|
|
REBALANCE_INTERVAL_DAYS = 20
|
|
|
|
@classmethod
|
|
def run(
|
|
cls,
|
|
series_by_class: dict[str, list[BacktestPrice]],
|
|
strategic_weights: dict[str, int],
|
|
*,
|
|
return_target_lower_pct: Decimal,
|
|
max_drawdown_pct: Decimal,
|
|
liquidity_requirement: str,
|
|
) -> AllocationBacktestResult:
|
|
missing = set(_ASSET_CLASSES) - set(series_by_class)
|
|
if missing:
|
|
return cls._unavailable(
|
|
"data_quality_required", ("missing_quality_approved_asset_class",)
|
|
)
|
|
by_date = {
|
|
asset_class: {item.trade_date: item for item in rows if item.close_price > 0}
|
|
for asset_class, rows in series_by_class.items()
|
|
}
|
|
common_dates = sorted(set.intersection(*(set(rows) for rows in by_date.values())))
|
|
if len(common_dates) <= cls.LOOKBACK_DAYS + 1:
|
|
return cls._unavailable("insufficient_history", ("insufficient_common_history",))
|
|
|
|
turnover_points = [
|
|
by_date[asset_class][trade_date].turnover_amount
|
|
for asset_class in _ASSET_CLASSES
|
|
for trade_date in common_dates
|
|
]
|
|
liquidity_coverage = cls._coverage(turnover_points)
|
|
limitations = () if liquidity_coverage == _HUNDRED else ("historical_turnover_incomplete",)
|
|
static_value = dynamic_value = Decimal("1")
|
|
static_peak = dynamic_peak = Decimal("1")
|
|
static_drawdown = dynamic_drawdown = Decimal()
|
|
dynamic_weights = dict(strategic_weights)
|
|
rebalances = 0
|
|
|
|
for index in range(cls.LOOKBACK_DAYS + 1, len(common_dates)):
|
|
if (index - cls.LOOKBACK_DAYS - 1) % cls.REBALANCE_INTERVAL_DAYS == 0:
|
|
metrics = cls._metrics(
|
|
by_date,
|
|
common_dates[index - cls.LOOKBACK_DAYS - 1:index],
|
|
liquidity_coverage,
|
|
)
|
|
dynamic_weights = DynamicAllocationOptimizer.optimize(
|
|
strategic_weights,
|
|
metrics,
|
|
return_target_lower_pct=return_target_lower_pct,
|
|
max_drawdown_pct=max_drawdown_pct,
|
|
liquidity_requirement=liquidity_requirement,
|
|
).weights
|
|
rebalances += 1
|
|
day_returns = {
|
|
asset_class: (
|
|
by_date[asset_class][common_dates[index]].close_price
|
|
/ by_date[asset_class][common_dates[index - 1]].close_price
|
|
- 1
|
|
)
|
|
for asset_class in _ASSET_CLASSES
|
|
}
|
|
static_value *= 1 + cls._weighted_return(day_returns, strategic_weights)
|
|
dynamic_value *= 1 + cls._weighted_return(day_returns, dynamic_weights)
|
|
static_peak = max(static_peak, static_value)
|
|
dynamic_peak = max(dynamic_peak, dynamic_value)
|
|
static_drawdown = min(static_drawdown, static_value / static_peak - 1)
|
|
dynamic_drawdown = min(dynamic_drawdown, dynamic_value / dynamic_peak - 1)
|
|
|
|
return AllocationBacktestResult(
|
|
status="ready" if not limitations else "partial",
|
|
started_on=common_dates[cls.LOOKBACK_DAYS],
|
|
ended_on=common_dates[-1],
|
|
observation_count=len(common_dates) - cls.LOOKBACK_DAYS,
|
|
static_total_return_pct=cls._pct(static_value - 1),
|
|
dynamic_total_return_pct=cls._pct(dynamic_value - 1),
|
|
static_max_drawdown_pct=cls._pct(static_drawdown),
|
|
dynamic_max_drawdown_pct=cls._pct(dynamic_drawdown),
|
|
dynamic_rebalance_count=rebalances,
|
|
liquidity_history_coverage_pct=liquidity_coverage,
|
|
limitations=limitations,
|
|
)
|
|
|
|
@classmethod
|
|
def record(
|
|
cls,
|
|
result: AllocationBacktestResult,
|
|
*,
|
|
profile_risk_level: str,
|
|
return_target_lower_pct: Decimal,
|
|
max_drawdown_pct: Decimal,
|
|
liquidity_requirement: str,
|
|
) -> AllocationBacktestRun:
|
|
if result.started_on is None or result.ended_on is None:
|
|
started_on = ended_on = date.today()
|
|
else:
|
|
started_on, ended_on = result.started_on, result.ended_on
|
|
return AllocationBacktestRun(
|
|
backtest_no=f"bt-{uuid4().hex[:24]}",
|
|
started_on=started_on,
|
|
ended_on=ended_on,
|
|
profile_risk_level=profile_risk_level,
|
|
return_target_lower_pct=return_target_lower_pct,
|
|
max_drawdown_pct=max_drawdown_pct,
|
|
liquidity_requirement=liquidity_requirement,
|
|
status=result.status,
|
|
observation_count=result.observation_count,
|
|
static_total_return_pct=result.static_total_return_pct,
|
|
dynamic_total_return_pct=result.dynamic_total_return_pct,
|
|
static_max_drawdown_pct=result.static_max_drawdown_pct,
|
|
dynamic_max_drawdown_pct=result.dynamic_max_drawdown_pct,
|
|
dynamic_rebalance_count=result.dynamic_rebalance_count,
|
|
liquidity_history_coverage_pct=result.liquidity_history_coverage_pct,
|
|
limitations=list(result.limitations),
|
|
strategy_version=cls.STRATEGY_VERSION,
|
|
created_at=datetime.now(UTC).replace(tzinfo=None),
|
|
)
|
|
|
|
@classmethod
|
|
def _metrics(
|
|
cls,
|
|
by_date: dict[str, dict[date, BacktestPrice]],
|
|
window_dates: list[date],
|
|
liquidity_coverage: Decimal,
|
|
) -> list[AssetClassMarketMetric]:
|
|
metrics: list[AssetClassMarketMetric] = []
|
|
for asset_class in _ASSET_CLASSES:
|
|
prices = [by_date[asset_class][trade_date] for trade_date in window_dates]
|
|
calculated = ProductMetricService.calculate(prices)
|
|
if calculated.trailing_120d_return_pct is None or calculated.max_drawdown_pct is None:
|
|
continue
|
|
liquidity = calculated.average_daily_turnover_amount or Decimal()
|
|
metrics.append(AssetClassMarketMetric(
|
|
asset_class=asset_class,
|
|
trailing_120d_return_pct=calculated.trailing_120d_return_pct,
|
|
max_drawdown_pct=calculated.max_drawdown_pct,
|
|
average_daily_turnover_amount=liquidity,
|
|
product_count=1,
|
|
liquidity_source=("historical_turnover" if liquidity_coverage == _HUNDRED
|
|
else "historical_turnover_incomplete"),
|
|
))
|
|
return metrics
|
|
|
|
@staticmethod
|
|
def _weighted_return(day_returns: dict[str, Decimal], weights: dict[str, int]) -> Decimal:
|
|
return sum(
|
|
(day_returns[asset_class] * Decimal(weights[asset_class]) / _HUNDRED
|
|
for asset_class in _ASSET_CLASSES),
|
|
Decimal(),
|
|
)
|
|
|
|
@staticmethod
|
|
def _coverage(values: list[Decimal | None]) -> Decimal:
|
|
if not values:
|
|
return Decimal()
|
|
return (
|
|
Decimal(sum(value is not None and value > 0 for value in values))
|
|
/ Decimal(len(values))
|
|
* _HUNDRED
|
|
).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
|
|
|
@staticmethod
|
|
def _pct(value: Decimal) -> Decimal:
|
|
return (value * _HUNDRED).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
|
|
|
|
@staticmethod
|
|
def _unavailable(status: str, limitations: tuple[str, ...]) -> AllocationBacktestResult:
|
|
return AllocationBacktestResult(
|
|
status=status,
|
|
started_on=None,
|
|
ended_on=None,
|
|
observation_count=0,
|
|
static_total_return_pct=None,
|
|
dynamic_total_return_pct=None,
|
|
static_max_drawdown_pct=None,
|
|
dynamic_max_drawdown_pct=None,
|
|
dynamic_rebalance_count=0,
|
|
liquidity_history_coverage_pct=Decimal(),
|
|
limitations=limitations,
|
|
)
|