Files
group_fqcd_jr/app/service/allocation_backtest_service.py
T

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,
)