diff --git a/src/fxhnt/domain/equity_backtest.py b/src/fxhnt/domain/equity_backtest.py index 250c588..e0debed 100644 --- a/src/fxhnt/domain/equity_backtest.py +++ b/src/fxhnt/domain/equity_backtest.py @@ -4,6 +4,8 @@ from __future__ import annotations import datetime as dt +from fxhnt.domain.strategies.equity_factor import book_return + _SECONDS_PER_DAY = 86_400 @@ -43,3 +45,23 @@ def month_end_rebalance_dates(dates: list[str]) -> list[str]: y, m, _ = d.split("-") last_by_month[(int(y), int(m))] = d return [last_by_month[k] for k in sorted(last_by_month)] + + +def daily_returns_for_weights( + weights: dict[str, float], + closes: dict[str, dict[int, float]], + days: list[int], + borrow_daily: float, +) -> list[float]: + """Daily book returns of fixed `weights` over consecutive `days` (epoch-days, + ascending). For each adjacent (prev, cur) pair, both prices must exist for a + symbol to contribute; a step with no usable price pair is dropped (not 0.0).""" + out: list[float] = [] + for prev, cur in zip(days, days[1:]): + prev_p = {s: closes[s][prev] for s in weights if s in closes and prev in closes[s] and cur in closes[s]} + cur_p = {s: closes[s][cur] for s in prev_p} + if not prev_p: + continue + w = {s: weights[s] for s in prev_p} + out.append(book_return(w, prev_p, cur_p, borrow_daily=borrow_daily)) + return out diff --git a/tests/unit/test_equity_backtest_domain.py b/tests/unit/test_equity_backtest_domain.py index e9e308b..e16d6c4 100644 --- a/tests/unit/test_equity_backtest_domain.py +++ b/tests/unit/test_equity_backtest_domain.py @@ -40,3 +40,32 @@ def test_month_end_rebalance_dates_one_per_month(): def test_epoch_day_roundtrip(): assert _epoch_day("1970-01-01") == 0 assert _epoch_day("1970-01-02") == 1 + + +import pytest + +from fxhnt.domain.equity_backtest import daily_returns_for_weights + + +def test_daily_returns_long_only_matches_weighted_price_change(): + weights = {"A": 0.5, "B": 0.5} + closes = { + "A": {0: 100.0, 1: 110.0}, + "B": {0: 50.0, 1: 50.0}, + } + rets = daily_returns_for_weights(weights, closes, [0, 1], borrow_daily=0.0) + assert rets == pytest.approx([0.05]) + + +def test_daily_returns_applies_borrow_on_short_notional(): + weights = {"A": -1.0} + closes = {"A": {0: 100.0, 1: 100.0}} + rets = daily_returns_for_weights(weights, closes, [0, 1], borrow_daily=0.001) + assert rets == [-0.001] + + +def test_daily_returns_skips_day_with_missing_price(): + weights = {"A": 1.0} + closes = {"A": {0: 100.0, 2: 121.0}} # day 1 missing + rets = daily_returns_for_weights(weights, closes, [0, 1, 2], borrow_daily=0.0) + assert rets == []