115 lines
4.7 KiB
Python
115 lines
4.7 KiB
Python
import datetime as dt
|
|
|
|
import numpy as np
|
|
|
|
from fxhnt.adapters.warehouse.duckdb_feature_store import DuckDbFeatureStore
|
|
from fxhnt.application.equity_backtest_runner import (
|
|
EquityBacktestRunner,
|
|
evaluate_constructions,
|
|
)
|
|
from fxhnt.domain.equity_backtest import month_end_rebalance_dates
|
|
|
|
_SPD = 86_400
|
|
|
|
|
|
def _ts(iso: str) -> int:
|
|
d = dt.date.fromisoformat(iso)
|
|
return (d - dt.date(1970, 1, 1)).days * _SPD
|
|
|
|
|
|
def _build_warehouse(path: str) -> DuckDbFeatureStore:
|
|
"""3 names over ~2 years of daily bars. AAA strong uptrend, BBB mild,
|
|
CCC downtrend — gives a non-degenerate cross-section for long/ls/tilt."""
|
|
store = DuckDbFeatureStore(path)
|
|
start = dt.date(2022, 1, 3)
|
|
n = 520 # ~2 trading years
|
|
drifts = {"AAA": 0.0015, "BBB": 0.0003, "CCC": -0.0010}
|
|
items = []
|
|
members = []
|
|
for sym, dr in drifts.items():
|
|
rows = []
|
|
px = 100.0
|
|
for i in range(n):
|
|
day = start + dt.timedelta(days=i)
|
|
px *= (1.0 + dr)
|
|
ts = (day - dt.date(1970, 1, 1)).days * _SPD
|
|
rows.append((ts, {"adjclose": px, "close": px, "volume": 1_000_000.0}))
|
|
items.append((sym, rows))
|
|
members.append((sym, "NYSE", "2000-01-01", "2026-06-01"))
|
|
store.write_features_bulk(items)
|
|
store.upsert_membership(members)
|
|
return store
|
|
|
|
|
|
def test_runner_produces_three_construction_series(tmp_path):
|
|
store = _build_warehouse(str(tmp_path / "wh.duckdb"))
|
|
runner = EquityBacktestRunner(store, n=10, cost_bps_per_turnover=15.0,
|
|
borrow_annual=0.0, momentum_min_history=252)
|
|
result = runner.run()
|
|
store.close()
|
|
assert set(result.returns_by_construction) == {"long", "ls", "tilt"}
|
|
for series in result.returns_by_construction.values():
|
|
assert len(series) > 50
|
|
assert np.isfinite(series).all()
|
|
assert float(np.prod(1.0 + result.returns_by_construction["long"]) - 1.0) > 0.0
|
|
|
|
# rebalance_dates lists only ACTUALLY-TRADED periods (len(kept) >= 2),
|
|
# never the 252d-warmup months where no book could be formed.
|
|
month_ends = month_end_rebalance_dates(
|
|
sorted({
|
|
(dt.date(2022, 1, 3) + dt.timedelta(days=i)).isoformat()
|
|
for i in range(520)
|
|
})
|
|
)
|
|
assert len(result.rebalance_dates) >= 1
|
|
assert all(d in month_ends for d in result.rebalance_dates)
|
|
# Warmup (252 trading-day history requirement) means fewer traded periods
|
|
# than the total monthly rebalance count.
|
|
assert len(result.rebalance_dates) < len(month_ends)
|
|
# All 3 synthetic names qualify once warmed up, so the average over TRADED
|
|
# periods is ~3.0 (not ~1.67, which would average in the empty warmup months).
|
|
assert abs(result.n_names_avg - 3.0) < 1e-9
|
|
|
|
|
|
def test_runner_is_deterministic(tmp_path):
|
|
store = _build_warehouse(str(tmp_path / "wh.duckdb"))
|
|
runner = EquityBacktestRunner(store, n=10, cost_bps_per_turnover=15.0,
|
|
borrow_annual=0.0, momentum_min_history=252)
|
|
a = runner.run().returns_by_construction["ls"]
|
|
b = runner.run().returns_by_construction["ls"]
|
|
store.close()
|
|
assert np.array_equal(a, b)
|
|
|
|
|
|
def test_momentum_min_history_gates_inclusion(tmp_path):
|
|
# With an absurdly high min-history requirement, NO name ever has enough
|
|
# history, so every construction series is empty and n_names_avg is 0.
|
|
store = _build_warehouse(str(tmp_path / "wh.duckdb"))
|
|
runner = EquityBacktestRunner(store, n=10, cost_bps_per_turnover=15.0,
|
|
borrow_annual=0.0, momentum_min_history=100_000)
|
|
result = runner.run()
|
|
store.close()
|
|
assert result.n_names_avg == 0.0
|
|
for series in result.returns_by_construction.values():
|
|
assert len(series) == 0
|
|
|
|
|
|
def test_evaluate_constructions_emits_stats_and_verdict_per_construction(tmp_path):
|
|
store = _build_warehouse(str(tmp_path / "wh.duckdb"))
|
|
runner = EquityBacktestRunner(store, n=10, cost_bps_per_turnover=15.0,
|
|
borrow_annual=0.0, momentum_min_history=252)
|
|
result = runner.run()
|
|
store.close()
|
|
report = evaluate_constructions(result, oos_fraction=0.40, dsr_min=0.95,
|
|
oos_min_sharpe=0.0, max_is_oos_decay=0.50)
|
|
assert set(report["constructions"]) == {"long", "ls", "tilt"}
|
|
for c, block in report["constructions"].items():
|
|
assert {"stats", "verdict"} <= set(block)
|
|
assert {"sharpe", "cagr", "max_drawdown", "n_obs"} <= set(block["stats"])
|
|
assert {"passed", "dsr", "is_sharpe", "oos_sharpe", "n_trials", "reasons"} <= set(block["verdict"])
|
|
assert block["verdict"]["n_trials"] == 3
|
|
assert report["n_names_avg"] >= 0.0
|
|
assert isinstance(report["rebalance_dates"], list)
|
|
import json
|
|
json.dumps(report) # must not raise
|