149 lines
7.3 KiB
Python
149 lines
7.3 KiB
Python
"""Task 3 — `record_allocation`'s equity merge: `equity_allocation_inputs` wired in AFTER
|
|
`honest_allocation_inputs`, ONLY for funded `ibkr-equity` deploy sids, inside the `store is not None` branch.
|
|
|
|
BYTE-IDENTITY (Global Constraints): `record_allocation` with `store=None`, or with `store` but no equity
|
|
deploy sid, must be UNCHANGED — the equity provider must not even be called in either case.
|
|
|
|
Positive case: an equity deploy sid with a SHORT forward-nav record (<60 days, `policy.min_history_days`)
|
|
must NOT be dropped by the allocation engine's eligibility gate, because the merged honest series is much
|
|
longer than the forward record — the exact bug this task fixes (memory: "multistrat was dropped for <60
|
|
forward days")."""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import math
|
|
|
|
from fxhnt import registry
|
|
from fxhnt.adapters.persistence.forward_nav import ForwardNavRepo
|
|
from fxhnt.adapters.warehouse.timescale_feature_store import TimescaleFeatureStore
|
|
from fxhnt.application.allocation_ingest import record_allocation
|
|
from fxhnt.application.allocation_policy import AllocationPolicy
|
|
from fxhnt.application.forward_models import ForwardNavRow as NavRowDTO
|
|
from fxhnt.config import get_settings
|
|
from fxhnt.domain.strategies.multistrat import FUND_INSTRUMENTS
|
|
|
|
_BASE = dt.date(2026, 1, 1)
|
|
_AT = dt.datetime(2026, 7, 9, 23, 30)
|
|
|
|
|
|
def _seed_deploy_sid(repo: ForwardNavRepo, sid: str, *, days: int = 60, flip: int = 1) -> None:
|
|
dates = [(_BASE + dt.timedelta(days=i)).isoformat() for i in range(days)]
|
|
for i, d in enumerate(dates):
|
|
repo.upsert_rows([NavRowDTO(strategy_id=sid, date=d,
|
|
ret=(0.01 if (i % 2) == flip else -0.008), nav=1.0)], at=_AT)
|
|
|
|
|
|
def _seed_equity_pit(repo: ForwardNavRepo, *, days: int = 150) -> None:
|
|
"""Real-enough PIT closes+volumes for FUND_INSTRUMENTS (huge dollar-ADV so capacity never binds — this
|
|
test isolates the eligibility/plumbing fix, not the cost model).
|
|
|
|
`record_allocation` defaults `equity_execution_regime` to `"ucits"` (Task 2), so
|
|
`equity_allocation_inputs` sources each sleeve's ADV from the UCITS-mapped ticker's OWN
|
|
`yahoo_pit_volumes` (e.g. `CSPX.L`, not `SPY`) — the US ticker's volume seeded below is unused under
|
|
the default regime. Seed the same honest, high-coverage volume series under EACH sleeve's
|
|
`settings.ucits.map[sym].yahoo_ticker` too, so the ADV-coverage guard doesn't trip and the ucits-regime
|
|
capacity model has real data to size against (mirrors the US seeding above, just under the UCITS key)."""
|
|
ucits_map = get_settings().ucits.map
|
|
for j, sym in enumerate(FUND_INSTRUMENTS):
|
|
closes: dict[str, float] = {}
|
|
volumes: dict[str, float] = {}
|
|
price = 100.0 + 10.0 * j
|
|
for d in range(days):
|
|
date = (_BASE + dt.timedelta(days=d)).isoformat()
|
|
price *= 1.0 + 0.004 * math.sin(0.05 * d + j)
|
|
closes[date] = price
|
|
volumes[date] = 5_000_000.0 + 200_000.0 * math.sin(0.07 * d + j)
|
|
repo.snapshot_yahoo_closes(sym, closes, _AT)
|
|
repo.snapshot_yahoo_volumes(sym, volumes, _AT)
|
|
listing = ucits_map.get(sym)
|
|
if listing is not None:
|
|
repo.snapshot_yahoo_volumes(listing.yahoo_ticker, volumes, _AT)
|
|
|
|
|
|
def test_store_none_is_unaffected_even_with_an_equity_deploy_sid(tmp_path, monkeypatch):
|
|
dsn = f"sqlite:///{tmp_path}/eq_none.db"
|
|
repo = ForwardNavRepo(dsn)
|
|
repo.migrate()
|
|
monkeypatch.setitem(registry.STRATEGY_REGISTRY, "multistrat",
|
|
{"display_name": "M", "sleeve": "tradfi-multistrat", "venue": "ibkr-equity",
|
|
"gate_spec": {}, "tier": "deploy"})
|
|
_seed_deploy_sid(repo, "multistrat", days=60)
|
|
|
|
out_before = record_allocation(dsn, equity=20_000.0, store=None)
|
|
|
|
# the equity provider must never even be CALLED when store is None -- prove it, don't just assume it.
|
|
import fxhnt.application.equity_allocation_inputs as eq_mod
|
|
|
|
def _boom(*_a, **_kw):
|
|
raise AssertionError("equity_allocation_inputs must not be called when store is None")
|
|
|
|
monkeypatch.setattr(eq_mod, "equity_allocation_inputs", _boom)
|
|
out_after = record_allocation(dsn, equity=20_000.0, store=None)
|
|
|
|
assert out_after == out_before
|
|
|
|
|
|
def test_store_present_no_equity_sid_skips_provider_entirely(tmp_path, monkeypatch):
|
|
dsn = f"sqlite:///{tmp_path}/eq_no_sid.db"
|
|
repo = ForwardNavRepo(dsn)
|
|
repo.migrate()
|
|
monkeypatch.setitem(registry.STRATEGY_REGISTRY, "d1",
|
|
{"display_name": "D1", "sleeve": "x", "gate_spec": {}, "tier": "deploy"})
|
|
_seed_deploy_sid(repo, "d1", days=60)
|
|
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
|
|
|
import fxhnt.application.equity_allocation_inputs as eq_mod
|
|
|
|
def _boom(*_a, **_kw):
|
|
raise AssertionError("equity_allocation_inputs must not be called with no ibkr-equity deploy sid")
|
|
|
|
monkeypatch.setattr(eq_mod, "equity_allocation_inputs", _boom)
|
|
|
|
out = record_allocation(dsn, equity=20_000.0, store=store)
|
|
store.close()
|
|
|
|
assert "d1" in out
|
|
|
|
|
|
def test_equity_sid_with_short_forward_history_is_not_dropped(tmp_path, monkeypatch):
|
|
dsn = f"sqlite:///{tmp_path}/eq_sized.db"
|
|
repo = ForwardNavRepo(dsn)
|
|
repo.migrate()
|
|
monkeypatch.setitem(registry.STRATEGY_REGISTRY, "multistrat",
|
|
{"display_name": "M", "sleeve": "tradfi-multistrat", "venue": "ibkr-equity",
|
|
"gate_spec": {}, "tier": "deploy"})
|
|
_seed_equity_pit(repo, days=150)
|
|
# SHORT forward nav record -- well under policy.min_history_days (60) -- the pre-fix drop condition.
|
|
_seed_deploy_sid(repo, "multistrat", days=20)
|
|
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
|
# capacity_min_sharpe=-10 isolates the eligibility/plumbing fix from the modeled book's actual Sharpe
|
|
# sign; capital_ceiling raised so the $100k target isn't clipped by the human gross-dollar gate.
|
|
pol = AllocationPolicy(capacity_min_sharpe=-10.0, capital_ceiling=1_000_000.0, marginal_gate=False)
|
|
|
|
out = record_allocation(dsn, equity=100_000.0, store=store, policy=pol)
|
|
store.close()
|
|
|
|
assert "multistrat" in out
|
|
assert out["multistrat"] != 0.0
|
|
|
|
|
|
def test_equity_sid_alone_with_only_short_forward_history_and_no_merge_would_be_dropped(tmp_path, monkeypatch):
|
|
"""Sanity check on the fixture itself: WITHOUT the equity merge (no PIT data seeded, so
|
|
equity_allocation_inputs finds nothing and the sid falls back to its 20-day nav record), the sid IS
|
|
dropped by the eligibility gate -- proving the positive test above is exercising the real fix, not a
|
|
fixture that would pass regardless."""
|
|
dsn = f"sqlite:///{tmp_path}/eq_dropped.db"
|
|
repo = ForwardNavRepo(dsn)
|
|
repo.migrate()
|
|
monkeypatch.setitem(registry.STRATEGY_REGISTRY, "multistrat",
|
|
{"display_name": "M", "sleeve": "tradfi-multistrat", "venue": "ibkr-equity",
|
|
"gate_spec": {}, "tier": "deploy"})
|
|
_seed_deploy_sid(repo, "multistrat", days=20) # no PIT data seeded -> no honest series available
|
|
store = TimescaleFeatureStore("sqlite://", table="bybit_features")
|
|
pol = AllocationPolicy(capacity_min_sharpe=-10.0, capital_ceiling=1_000_000.0, marginal_gate=False)
|
|
|
|
out = record_allocation(dsn, equity=100_000.0, store=store, policy=pol)
|
|
store.close()
|
|
|
|
assert out.get("multistrat", 0.0) == 0.0
|