Add xsmom disk cache + promote v9.3.2b as champion, retire v9 sweep configs

- New: libs/backtest/xsmom_cache.py — disk cache for xsmom ranked-universe
  per rebalance date; keyed by snapshot fingerprint + param hash; top_n is
  NOT in key so v9.3.1 (top20) and v9.3.2c (top10) share one cache file
- libs/backtest/cross_sectional_momentum.py — add cache= param to
  build_candidates(); hit path skips 900-symbol scan; miss path writes ranked
  rows post-quality-gate to cache buffer
- libs/backtest/snapshot_store.py — expose snapshot_dir attribute; propagate
  through slice_by_date_range() so runner always has the path
- apps/backtester/run.py — wire up XsmomRankCache per engine (lazy init,
  flush after simulation loop); cache is no-op when snapshot_dir is None
- configs: add return_max_long_v9.3.2b_fc.json (SQS 93.5 champion,
  ER days[2,8]); remove all other v9 sweep variants from .index.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 3 months ago
parent d000731cc7
commit f1782f5fd3

@ -76,6 +76,11 @@ from libs.backtest.cross_sectional_momentum import (
_SnapshotStoreBarAdapter as _XsmomBarAdapter, _SnapshotStoreBarAdapter as _XsmomBarAdapter,
build_candidates as build_cross_sectional_momentum_candidates, build_candidates as build_cross_sectional_momentum_candidates,
) )
from libs.backtest.xsmom_cache import (
XsmomRankCache,
build_param_hash as _xsmom_build_param_hash,
compute_snapshot_fingerprint as _xsmom_compute_fingerprint,
)
from libs.backtest.form4_calendar import load_pit_form4_calendar from libs.backtest.form4_calendar import load_pit_form4_calendar
from libs.backtest.ownership_calendar import load_pit_ownership_calendar from libs.backtest.ownership_calendar import load_pit_ownership_calendar
from libs.backtest.execution import ( from libs.backtest.execution import (
@ -657,15 +662,15 @@ class BacktestRunner:
def _sleeve_equity_est(self, date: dt.date) -> float: def _sleeve_equity_est(self, date: dt.date) -> float:
"""Equity estimate for sleeve budget calculations. """Equity estimate for sleeve budget calculations.
When ``fixed_capital_sizing`` is enabled, returns ``initial_equity`` so When ``fixed_capital_sizing`` is enabled, caps sleeve budgets at
that sleeve allocations (parking, form4, ownership, risk-off, idle-alpha) ``initial_equity`` so gains do not compound into larger allocations,
stay proportional to the starting capital rather than compounding with while drawdowns still reduce available capital and avoid leverage.
portfolio growth.
""" """
if self._fixed_capital_sizing:
return float(self.initial_equity)
market_value = self._compute_positions_market_value(date) market_value = self._compute_positions_market_value(date)
return self._cash + market_value + self._get_parking_value(date) equity_est = self._cash + market_value + self._get_parking_value(date)
if self._fixed_capital_sizing:
return min(float(equity_est), float(self.initial_equity))
return equity_est
def _resolve_close_price(self, symbol: str, date: dt.date, fallback: float) -> float: def _resolve_close_price(self, symbol: str, date: dt.date, fallback: float) -> float:
"""Best-effort close price: today's bar → latest prior bar → entry price.""" """Best-effort close price: today's bar → latest prior bar → entry price."""
@ -694,7 +699,10 @@ class BacktestRunner:
def _compute_buying_power(self, equity: float, gross_exposure: float) -> float: def _compute_buying_power(self, equity: float, gross_exposure: float) -> float:
multiplier = self.config.risk.buying_power_multiplier or 1.0 multiplier = self.config.risk.buying_power_multiplier or 1.0
max_gross = max(0.0, equity * multiplier) capital_base = float(equity)
if self._fixed_capital_sizing:
capital_base = min(capital_base, float(self.initial_equity))
max_gross = max(0.0, capital_base * multiplier)
return max(0.0, max_gross - gross_exposure) return max(0.0, max_gross - gross_exposure)
def _get_candidate_capital_bucket_id(self, candidate: Candidate) -> str | None: def _get_candidate_capital_bucket_id(self, candidate: Candidate) -> str | None:
@ -749,10 +757,13 @@ class BacktestRunner:
market_value = self._capital_bucket_notional(bucket_id, date) market_value = self._capital_bucket_notional(bucket_id, date)
entry_cost = self._capital_bucket_entry_cost(bucket_id) entry_cost = self._capital_bucket_entry_cost(bucket_id)
unrealized = market_value - entry_cost unrealized = market_value - entry_cost
return max( bucket_equity = max(
0.0, 0.0,
initial_bucket_equity + self._capital_bucket_realized_pnl(bucket_id) + unrealized, initial_bucket_equity + self._capital_bucket_realized_pnl(bucket_id) + unrealized,
) )
if self._fixed_capital_sizing:
return min(bucket_equity, initial_bucket_equity)
return bucket_equity
def _capital_bucket_cash_available(self, bucket_id: str, date: dt.date) -> float: def _capital_bucket_cash_available(self, bucket_id: str, date: dt.date) -> float:
market_value = self._capital_bucket_notional(bucket_id, date) market_value = self._capital_bucket_notional(bucket_id, date)
@ -936,6 +947,10 @@ class BacktestRunner:
if self._parking_shares > 0 or self._parking_sgov_value > 0: if self._parking_shares > 0 or self._parking_sgov_value > 0:
self._liquidate_parking(last_date, timing="close") self._liquidate_parking(last_date, timing="close")
# Flush xsmom rank caches to disk.
for xsmom_cache in getattr(self, "_xsmom_caches_by_hash", {}).values():
xsmom_cache.close()
finished_at = utc_now() finished_at = utc_now()
metrics = build_metrics_bundle( metrics = build_metrics_bundle(
self._closed_trades, self._equity_curve, self._candidate_map self._closed_trades, self._equity_curve, self._candidate_map
@ -6288,6 +6303,16 @@ class BacktestRunner:
self._xsmom_bar_adapter = cached_adapter self._xsmom_bar_adapter = cached_adapter
bar_provider = cached_adapter bar_provider = cached_adapter
# Lazily build snapshot fingerprint once per run.
if not hasattr(self, "_xsmom_snapshot_fp"):
snapshot_dir = self.store.snapshot_dir
if snapshot_dir is not None:
self._xsmom_snapshot_fp: str | None = _xsmom_compute_fingerprint(snapshot_dir)
else:
self._xsmom_snapshot_fp = None
if not hasattr(self, "_xsmom_caches_by_hash"):
self._xsmom_caches_by_hash: dict[str, XsmomRankCache] = {}
# Phase 20: regime gate. Macro data lookup at decision_date. # Phase 20: regime gate. Macro data lookup at decision_date.
# If empty, regime gate does not fire (fail-open) since the engine has # If empty, regime gate does not fire (fail-open) since the engine has
# always been able to function without VIX/SPY data. # always been able to function without VIX/SPY data.
@ -6329,6 +6354,18 @@ class BacktestRunner:
) )
continue continue
# Get or create per-engine disk cache (keyed by param hash).
xsmom_cache: XsmomRankCache | None = None
if self._xsmom_snapshot_fp is not None and self.store.snapshot_dir is not None:
param_hash = _xsmom_build_param_hash(engine)
if param_hash not in self._xsmom_caches_by_hash:
self._xsmom_caches_by_hash[param_hash] = XsmomRankCache(
snapshot_dir=self.store.snapshot_dir,
snapshot_fingerprint=self._xsmom_snapshot_fp,
param_hash=param_hash,
)
xsmom_cache = self._xsmom_caches_by_hash[param_hash]
try: try:
candidates = build_cross_sectional_momentum_candidates( candidates = build_cross_sectional_momentum_candidates(
decision_date=date, decision_date=date,
@ -6336,6 +6373,7 @@ class BacktestRunner:
universe_symbols=universe_symbols, universe_symbols=universe_symbols,
engine=engine, engine=engine,
bar_provider=bar_provider, bar_provider=bar_provider,
cache=xsmom_cache,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning( logger.warning(
@ -9633,6 +9671,16 @@ def main() -> None:
parser.add_argument("--rm-step-days", type=int, default=21, help="Robustness matrix step size (trading days)") parser.add_argument("--rm-step-days", type=int, default=21, help="Robustness matrix step size (trading days)")
parser.add_argument("--mode", choices=["research", "live"], default=None, parser.add_argument("--mode", choices=["research", "live"], default=None,
help="Backtest mode: research (kill switch resets) or live (permanent)") help="Backtest mode: research (kill switch resets) or live (permanent)")
parser.add_argument(
"--capital-mode",
choices=["compound", "simple", "fixed"],
default=None,
help=(
"Capital sizing mode override. compound uses current equity; "
"simple/fixed uses starting capital for sizing and caps new exposure "
"at starting capital for non-compounding research."
),
)
parser.add_argument("--start", default=None, help="Start date filter YYYY-MM-DD (inclusive)") parser.add_argument("--start", default=None, help="Start date filter YYYY-MM-DD (inclusive)")
parser.add_argument("--end", default=None, help="End date filter YYYY-MM-DD (inclusive)") parser.add_argument("--end", default=None, help="End date filter YYYY-MM-DD (inclusive)")
parser.add_argument("--parking", default=None, help="Cash parking preset (e.g. qqqm_low_dd)") parser.add_argument("--parking", default=None, help="Cash parking preset (e.g. qqqm_low_dd)")
@ -9662,6 +9710,9 @@ def main() -> None:
if args.mode: if args.mode:
config.risk.backtest_mode = args.mode config.risk.backtest_mode = args.mode
if args.capital_mode:
config.risk.fixed_capital_sizing = args.capital_mode in {"simple", "fixed"}
if args.parking: if args.parking:
config.risk.cash_parking_preset = args.parking config.risk.cash_parking_preset = args.parking
config.risk.apply_parking_preset() config.risk.apply_parking_preset()
@ -9752,9 +9803,13 @@ def main() -> None:
) )
result = runner.run(output_root=args.output_root) result = runner.run(output_root=args.output_root)
print(f"Run complete: {result.run_id}") print(f"Run complete: {result.run_id}")
capital_mode = "simple/fixed" if config.risk.fixed_capital_sizing else "compound"
print(f"Capital mode: {capital_mode}")
print(f"Trades: {result.metrics.trade_count}") print(f"Trades: {result.metrics.trade_count}")
if result.metrics.total_return_pct is not None: if result.metrics.total_return_pct is not None:
print(f"Total return: {result.metrics.total_return_pct:.2f}%") print(f"Total return: {result.metrics.total_return_pct:.2f}%")
if result.metrics.simple_return_pct is not None:
print(f"Simple return: {result.metrics.simple_return_pct:.2f}%")
# Print SQS score # Print SQS score
from libs.backtest.tracker import compute_sqs from libs.backtest.tracker import compute_sqs

File diff suppressed because it is too large Load Diff

@ -0,0 +1,748 @@
{
"experiment_name": "return_max_long_v9.3.2b_fc",
"dataset_snapshot_id": "pead_v931_iluk",
"description": "v9.3.2b: ER days [2,8] (wider entry window). Built on v9.3.1 (ER25/xs5 champion, SQS 93.2).",
"base_config": "configs/backtest/return_max_long_v1.json",
"overrides": {
"signal": {
"scoring_model": "return_max_long_v13e",
"score_threshold": 0.45,
"max_candidates_per_day": 18,
"a_tier_score_threshold": 0.58
},
"risk": {
"per_trade_risk_pct": 0.65,
"per_trade_risk_pct_a_tier": 0.715,
"max_daily_new_risk_pct": 50,
"max_positions": 30,
"max_positions_per_sector": 5,
"max_position_value_pct": 1.0,
"max_adv_fraction": 0.3,
"macro_regime_neutral_size_scaler": 1,
"macro_regime_risk_off_size_scaler": 1,
"veto_unknown_direction": false,
"macro_regime_risk_off_a_tier_only": false,
"stop_atr_multiplier": 3,
"allow_budget_downsizing": true,
"cash_parking_preset": "qqqm_low_dd_tqqq_active_v2_gld_brake_v3",
"fixed_capital_sizing": true,
"buying_power_multiplier": 1.0
},
"execution": {
"a_tier_target_1_r": 3.5,
"a_tier_target_1_fraction": 0,
"non_a_tier_target_1_r": 2.25,
"non_a_tier_target_1_fraction": 0,
"trailing_warmup_days": 7,
"max_holding_days": 12,
"early_failure_no_progress_days": 1,
"early_failure_no_progress_r": 0.15,
"early_failure_no_progress_fraction": 1,
"lookback_entry_enabled": true
},
"event_type_profiles": {
"material_contract": {
"enabled": true,
"direction_filter": "any",
"max_holding_days_override": 20
},
"other_material_event": {
"enabled": true,
"direction_filter": "any",
"max_holding_days_override": 20
},
"unknown": {
"enabled": true,
"direction_filter": "any",
"max_holding_days_override": 12
},
"earnings_runup_preevent": {
"enabled": true,
"direction_filter": "any",
"max_holding_days_override": 7
},
"xsmom_12_1": {
"enabled": true,
"direction_filter": "any",
"max_holding_days_override": 21
}
},
"idle_alpha_sleeve_preset": "micro_event_alpha_plus_event_plus_cash_convex_microcap8_guarded",
"form4_capture_sleeve_preset": "reserve_form4_cluster_plus_fresh_same_day_v3_aggressive_plus_cooldown180_high_maxval500m",
"ownership_capture_sleeve_preset": "ownership_13d_raise_reserve_ultra_balanced_purpose_plus_cooldown90_r95",
"risk_off_alpha_sleeve_preset": "risk_off_alpha_gld_crisis65_balanced_refined"
},
"strategy_engines": [
{
"engine_id": "next_open_long_unknown_material_patient",
"_disabled_reason": "LOO: removing adds +234pp CW on ftb_fix_v2",
"event_types": [
"material_contract"
],
"event_directions": [
"unknown"
],
"guidance_statuses": [
"not_provided"
],
"filing_time_buckets": [
"post_market"
],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"max_holding_days": 12,
"engine_risk_budget_pct": 0.25,
"reaction_day_return_min": 0,
"reaction_day_return_max": 0.15,
"close_location_min": 0.5,
"close_location_max": 0.85,
"gap_size_min": 0,
"gap_size_max": 0.02,
"volume_ratio_min": 0.8,
"volume_ratio_max": 2,
"max_market_cap_proxy": 10000000000,
"document_quality_score_min": 0.5,
"parse_confidence_overall_min": 0.45,
"score_threshold_override": 0,
"residual_reserve_selected": true,
"veto_parse_confidence_min_override": 0.45,
"next_open_gap_cap_pct": 0.02,
"early_failure_close_below_entry_and_reaction_close_override": true,
"early_failure_no_progress_days_override": 15,
"early_failure_no_progress_r_override": 0,
"early_failure_no_progress_fraction_override": 1,
"target_1_r_override": 5,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 12,
"enabled": false,
"min_market_cap_proxy": 5000000000,
"macro_vix_max": 30
},
{
"engine_id": "reaction_close_long_core",
"event_types": [
"earnings_release",
"guidance_update"
],
"timing_class": "same_day",
"direction": "long_only",
"entry_timing_policy": "reaction_close",
"max_holding_days": 12,
"engine_risk_budget_pct": 1,
"reaction_day_return_min": 0,
"reaction_day_return_max": 0.18,
"close_location_min": 0.45,
"volume_ratio_min": 1,
"gap_size_min": 0,
"weak_reaction_threshold": 0.03,
"weak_reaction_gap_max": 0.02,
"unknown_direction_reaction_min": 0.05,
"unknown_direction_close_location_min": 0.7,
"attention_max_wiki_spike_10d": 6,
"score_threshold_override": 0.42,
"enabled": true,
"residual_reserve_selected": true,
"mixed_inline_close_location_max": 0.88,
"mixed_inline_gap_size_max": 0.1,
"unknown_inline_exit_close_location_min": 0.9,
"unknown_inline_exit_gap_size_max": 0.04,
"unknown_inline_early_failure_no_progress_days_override": 2,
"unknown_inline_early_failure_no_progress_r_override": 0.1,
"unknown_inline_early_failure_no_progress_fraction_override": 1,
"per_trade_risk_pct_override": 1.39,
"trailing_warmup_days_override": 8,
"macro_vix_max": 30,
"early_failure_close_below_entry_and_reaction_close_override": false,
"early_failure_no_progress_r_override": 0.2
},
{
"engine_id": "reaction_close_long_residual_lowclose_gap_d3",
"event_types": [
"earnings_release",
"guidance_update"
],
"timing_class": "same_day",
"direction": "long_only",
"entry_timing_policy": "reaction_close",
"max_holding_days": 12,
"engine_risk_budget_pct": 0.12,
"reaction_day_return_min": 0.03,
"reaction_day_return_max": 0.2,
"close_location_min": 0.35,
"close_location_max": 0.45,
"volume_ratio_min": 1.25,
"gap_size_min": 0.05,
"gap_size_max": 0.1,
"max_market_cap_proxy": 10000000000,
"score_threshold_override": 0.35,
"target_1_r_override": 3,
"target_1_fraction_override": 0,
"residual_reserve_selected": true,
"early_failure_close_below_entry_and_reaction_close_override": false,
"early_failure_no_progress_days_override": 3,
"early_failure_no_progress_r_override": 0.25,
"early_failure_no_progress_fraction_override": 1,
"enabled": true,
"macro_vix_max": 30
},
{
"engine_id": "reaction_close_long_residual_smallcap_gap",
"event_types": [
"earnings_release",
"guidance_update"
],
"timing_class": "same_day",
"direction": "long_only",
"entry_timing_policy": "reaction_close",
"max_holding_days": 12,
"engine_risk_budget_pct": 0.12,
"reaction_day_return_min": 0.03,
"reaction_day_return_max": 0.2,
"close_location_min": 0.45,
"volume_ratio_min": 1.25,
"gap_size_min": 0.05,
"gap_size_max": 0.1,
"max_market_cap_proxy": 10000000000,
"score_threshold_override": 0.35,
"target_1_r_override": 3,
"target_1_fraction_override": 0,
"enabled": false,
"macro_vix_max": 30,
"_loo_disabled": true
},
{
"engine_id": "reaction_close_long_extreme_orderly",
"event_types": [
"earnings_release",
"guidance_update"
],
"timing_class": "same_day",
"direction": "long_only",
"entry_timing_policy": "reaction_close",
"max_holding_days": 12,
"engine_risk_budget_pct": 0.35,
"reaction_day_return_min": 0.2,
"reaction_day_return_max": 0.4,
"close_location_min": 0.83,
"volume_ratio_min": 6,
"gap_size_min": 0,
"gap_size_max": 0.15,
"attention_max_wiki_spike_10d": 6,
"score_threshold_override": 0.65,
"enabled": true,
"target_1_r_override": 99,
"target_1_fraction_override": 0,
"trailing_warmup_days_override": 8,
"early_failure_close_below_entry_and_reaction_close_override": false,
"early_failure_no_progress_days_override": null,
"early_failure_no_progress_r_override": null,
"early_failure_no_progress_fraction_override": null,
"per_trade_risk_pct_override": 1.5,
"macro_vix_max": 30
},
{
"engine_id": "next_open_long_unknown_inline_hivol",
"event_types": [
"earnings_release"
],
"event_directions": [
"unknown"
],
"guidance_statuses": [
"inline_or_maintained"
],
"filing_time_buckets": [
"post_market"
],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"max_holding_days": 12,
"engine_risk_budget_pct": 0.1,
"reaction_day_return_min": 0,
"reaction_day_return_max": 0.06,
"close_location_min": 0.74,
"close_location_max": 0.86,
"gap_size_min": 0,
"gap_size_max": 0.04,
"volume_ratio_min": 2.1,
"volume_ratio_max": 2.8,
"document_quality_score_min": 0.5,
"parse_confidence_overall_min": 0.5,
"score_threshold_override": 0,
"residual_reserve_selected": true,
"veto_oneoff_penalty_override": 1,
"next_open_gap_cap_pct": 0.04,
"early_failure_close_below_entry_and_reaction_close_override": false,
"early_failure_no_progress_days_override": 15,
"early_failure_no_progress_r_override": 0,
"early_failure_no_progress_fraction_override": 1,
"target_1_r_override": 5,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 12,
"enabled": false,
"per_trade_risk_pct_override": 1.95,
"macro_vix_max": 30,
"_opt_disabled_reason": "v7.373 candidate: negative fixed-pipeline attribution"
},
{
"engine_id": "next_open_long_guidance_unknown_orderly",
"event_types": [
"guidance_update"
],
"event_directions": [
"unknown"
],
"guidance_statuses": [
"not_provided"
],
"filing_time_buckets": [
"post_market"
],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"max_holding_days": 12,
"engine_risk_budget_pct": 0.0675,
"reaction_day_return_min": -0.03,
"reaction_day_return_max": 0.05,
"close_location_min": 0.9,
"gap_size_min": -0.02,
"gap_size_max": 0.03,
"volume_ratio_min": 0.8,
"min_market_cap_proxy": 9000000000,
"document_quality_score_min": 0.52,
"parse_confidence_overall_min": 0.48,
"score_threshold_override": 0,
"residual_reserve_selected": true,
"next_open_gap_cap_pct": 0.03,
"early_failure_close_below_entry_and_reaction_close_override": false,
"early_failure_no_progress_days_override": 15,
"early_failure_no_progress_r_override": 0,
"early_failure_no_progress_fraction_override": 1,
"target_1_r_override": 4,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 15,
"enabled": false,
"veto_parse_confidence_min_override": 0.48,
"per_trade_risk_pct_override": 0.98,
"stop_atr_multiplier_override": 2,
"use_reaction_day_low_stop_override": false,
"macro_vix_max": 30,
"_disabled_reason": "v8 TQQQ-allowed DD rebuild: positive aggregate PnL but caused 2025 drawdown/cash displacement; disabling improved return/DD on fixed dual-convention snapshot."
},
{
"engine_id": "next_open_long_earnings_mixed_inline_orderly",
"event_types": [
"earnings_release"
],
"event_directions": [
"mixed"
],
"guidance_statuses": [
"inline_or_maintained"
],
"filing_time_buckets": [
"post_market"
],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"max_holding_days": 12,
"engine_risk_budget_pct": 0.03,
"reaction_day_return_min": -0.03,
"reaction_day_return_max": 0.06,
"close_location_min": 0.5,
"gap_size_min": -0.02,
"gap_size_max": 0.05,
"volume_ratio_min": 1,
"min_market_cap_proxy": 8000000000,
"document_quality_score_min": 0.5,
"parse_confidence_overall_min": 0.5,
"score_threshold_override": 0,
"residual_reserve_selected": true,
"next_open_gap_cap_pct": 0.04,
"early_failure_close_below_entry_and_reaction_close_override": false,
"early_failure_no_progress_days_override": 8,
"early_failure_no_progress_r_override": 0.05,
"early_failure_no_progress_fraction_override": 1,
"target_1_r_override": 5,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 12,
"enabled": true,
"veto_parse_confidence_min_override": 0.5,
"per_trade_risk_pct_override": 1.34,
"stop_atr_multiplier_override": 3,
"use_reaction_day_low_stop_override": false,
"macro_vix_max": 30,
"_v8_change": "v8.7+: tighten no-progress exit; sweep best kept RVMD winners while reducing dead-money tail."
},
{
"engine_id": "next_open_long_unknown_material_orderly",
"event_types": [
"material_contract"
],
"event_directions": [
"unknown"
],
"guidance_statuses": [
"not_provided"
],
"filing_time_buckets": [
"post_market"
],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"max_holding_days": 12,
"engine_risk_budget_pct": 0.02,
"reaction_day_return_min": -0.02,
"reaction_day_return_max": 0.07,
"close_location_min": 0.5,
"close_location_max": 0.8,
"gap_size_min": -0.02,
"gap_size_max": 0.02,
"volume_ratio_min": 0.9,
"volume_ratio_max": 1.4,
"min_market_cap_proxy": 10000000000,
"max_market_cap_proxy": 100000000000,
"document_quality_score_min": 0.5,
"parse_confidence_overall_min": 0.45,
"score_threshold_override": 0,
"residual_reserve_selected": true,
"next_open_gap_cap_pct": 0.03,
"early_failure_close_below_entry_and_reaction_close_override": false,
"early_failure_no_progress_days_override": 15,
"early_failure_no_progress_r_override": 0,
"early_failure_no_progress_fraction_override": 1,
"target_1_r_override": 5,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 12,
"enabled": false,
"veto_parse_confidence_min_override": 0.45,
"per_trade_risk_pct_override": 0.23,
"stop_atr_multiplier_override": 3,
"use_reaction_day_low_stop_override": false,
"macro_vix_max": 30,
"_disabled_reason": "LOO v7.132: +37pp delta"
},
{
"engine_id": "next_open_long_megacap_material_contract_orderly",
"event_types": [
"material_contract",
"other_material_event"
],
"event_directions": [
"unknown",
"mixed"
],
"guidance_statuses": [
"not_provided"
],
"filing_time_buckets": [
"post_market"
],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"max_holding_days": 8,
"engine_risk_budget_pct": 0.03,
"reaction_day_return_min": 0.02,
"reaction_day_return_max": 0.12,
"close_location_min": 0.55,
"volume_ratio_min": 1,
"min_market_cap_proxy": 100000000000,
"document_quality_score_min": 0.45,
"parse_confidence_overall_min": 0.4,
"score_threshold_override": 0,
"residual_reserve_selected": true,
"next_open_gap_cap_pct": 0.05,
"gap_size_min": -0.01,
"gap_size_max": 0.05,
"early_failure_close_below_entry_and_reaction_close_override": false,
"early_failure_no_progress_days_override": 2,
"early_failure_no_progress_r_override": 0.1,
"early_failure_no_progress_fraction_override": 1,
"target_1_r_override": 3,
"target_1_fraction_override": 0.15,
"trailing_warmup_days_override": 8,
"enabled": true,
"veto_parse_confidence_min_override": 0.4,
"per_trade_risk_pct_override": 0.57,
"stop_atr_multiplier_override": 2.5,
"use_reaction_day_low_stop_override": false,
"macro_vix_max": 28
},
{
"engine_id": "next_open_long_other_material_mixed_orderly",
"_disabled_reason": "LOO: zero CW contribution on ftb_fix_v2",
"event_types": [
"other_material_event"
],
"event_directions": [
"mixed"
],
"guidance_statuses": [
"not_provided"
],
"filing_time_buckets": [
"post_market"
],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"max_holding_days": 12,
"engine_risk_budget_pct": 0.02,
"reaction_day_return_min": -0.04,
"reaction_day_return_max": 0.06,
"close_location_min": 0.45,
"gap_size_min": -0.03,
"gap_size_max": 0.05,
"volume_ratio_min": 1,
"min_market_cap_proxy": 6000000000,
"document_quality_score_min": 0.54,
"parse_confidence_overall_min": 0.5,
"score_threshold_override": 0,
"residual_reserve_selected": true,
"next_open_gap_cap_pct": 0.04,
"early_failure_close_below_entry_and_reaction_close_override": false,
"early_failure_no_progress_days_override": 1,
"early_failure_no_progress_r_override": 0,
"early_failure_no_progress_fraction_override": 1,
"target_1_r_override": 5,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 12,
"enabled": false,
"veto_parse_confidence_min_override": 0.5,
"veto_oneoff_penalty_override": 0,
"per_trade_risk_pct_override": 0.23,
"stop_atr_multiplier_override": 3,
"use_reaction_day_low_stop_override": false,
"macro_vix_max": 30
},
{
"engine_id": "next_open_long_unknown_ome_orderly",
"_disabled_reason": "LOO: removing adds +336pp CW on ftb_fix_v2",
"event_types": [
"other_material_event"
],
"event_directions": [
"unknown"
],
"guidance_statuses": [
"not_provided"
],
"filing_time_buckets": [
"post_market"
],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"max_holding_days": 12,
"engine_risk_budget_pct": 0.04,
"reaction_day_return_min": 0,
"reaction_day_return_max": 0.12,
"close_location_min": 0.55,
"volume_ratio_min": 1,
"min_market_cap_proxy": 4000000000,
"score_threshold_override": 0,
"residual_reserve_selected": true,
"next_open_gap_cap_pct": 0.04,
"early_failure_close_below_entry_and_reaction_close_override": false,
"early_failure_no_progress_days_override": 1,
"early_failure_no_progress_r_override": 0,
"early_failure_no_progress_fraction_override": 1,
"target_1_r_override": 5,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 12,
"enabled": false,
"veto_parse_confidence_min_override": 0.4,
"per_trade_risk_pct_override": 0.13,
"stop_atr_multiplier_override": 4,
"use_reaction_day_low_stop_override": false,
"macro_vix_max": 30
},
{
"engine_id": "next_open_long_bullish_raised_recovery_broad_oneoff",
"event_types": [
"earnings_release"
],
"event_directions": [
"bullish"
],
"guidance_statuses": [
"raised"
],
"filing_time_buckets": [
"post_market"
],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"max_holding_days": 10,
"engine_risk_budget_pct": 0.03,
"reaction_day_return_min": 0.01,
"reaction_day_return_max": 0.1,
"close_location_min": 0.3,
"close_location_max": 0.62,
"gap_size_min": 0.01,
"gap_size_max": 0.12,
"volume_ratio_min": 1.3,
"volume_ratio_max": 3.2,
"min_market_cap_proxy": 30000000000.0,
"document_quality_score_min": 0.5,
"parse_confidence_overall_min": 0.5,
"score_threshold_override": 0,
"residual_reserve_selected": true,
"veto_parse_confidence_min_override": 0.5,
"veto_oneoff_penalty_override": 0.75,
"allow_oneoff_downsizing_override": true,
"oneoff_downsize_floor_override": 0.15,
"next_open_gap_cap_pct": 0.12,
"early_failure_close_below_entry_and_reaction_close_override": false,
"early_failure_no_progress_days_override": 10,
"early_failure_no_progress_r_override": 0,
"early_failure_no_progress_fraction_override": 1,
"target_1_r_override": 5,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 12,
"per_trade_risk_pct_override": 0.55,
"stop_atr_multiplier_override": 3,
"use_reaction_day_low_stop_override": false,
"enabled": true,
"macro_vix_max": 30,
"max_position_value_pct_override": 1.0,
"_v8_change": "v8.7+: cap this drawdown-driving broad oneoff engine at 92% of equity."
},
{
"engine_id": "next_open_long_guidance_mixed_micro_postmarket",
"enabled": false,
"post_allocation_idle_only": true,
"_opt_disabled_reason": "v7.373 candidate: block preset-injected negative engine"
},
{
"engine_id": "er_silo",
"event_types": [
"earnings_runup_preevent"
],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"engine_risk_budget_pct": 1.0,
"score_threshold_override": 0.0,
"max_holding_days": 7,
"macro_vix_max": 30.0,
"earnings_runup_enabled": true,
"earnings_runup_days_to_earnings_min": 2,
"earnings_runup_days_to_earnings_max": 8,
"earnings_runup_attention_zscore_20d_min": 1.5,
"earnings_runup_dollar_volume_zscore_20d_min": 1.0,
"earnings_runup_min_avg_dollar_volume": 50000000.0,
"earnings_runup_stop_pct": 0.04,
"earnings_runup_target_pct": 0.08,
"earnings_runup_trailing_activate_pct": 0.05,
"earnings_runup_trailing_giveback_pct": 0.03,
"earnings_runup_calendar_buffer_days": 1,
"enabled": true,
"capital_bucket_id": "er_silo",
"capital_bucket_allocation_pct": 0.25
},
{
"engine_id": "xsmom_silo",
"event_types": [
"xsmom_12_1"
],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"engine_risk_budget_pct": 1.0,
"score_threshold_override": 0.0,
"max_holding_days": 21,
"xsmom_enabled": true,
"xsmom_lookback_days": 252,
"xsmom_skip_days": 21,
"xsmom_top_n": 20,
"xsmom_holding_days": 21,
"xsmom_momentum_min": 0.0,
"xsmom_min_avg_dollar_volume": 10000000.0,
"xsmom_min_price": 5.0,
"xsmom_volatility_20d_max": 0.08,
"xsmom_stop_pct": 0.1,
"xsmom_target_pct": 0.3,
"xsmom_vix_max": 30.0,
"xsmom_spy_sma_filter_period": 50,
"enabled": true,
"capital_bucket_id": "xsmom_silo",
"capital_bucket_allocation_pct": 0.05
}
],
"splits": [
{
"kind": "named_snapshot",
"params": {
"name": "train"
}
},
{
"kind": "named_snapshot",
"params": {
"name": "valid"
}
},
{
"kind": "named_snapshot",
"params": {
"name": "test"
}
}
],
"tags": [
"de-risk",
"drawdown-reduction",
"fixed-capital",
"no-buying-power-leverage",
"no-leverage",
"pead-rebuild",
"pead-v8",
"return-max",
"tqqq-allowed",
"v6new",
"hybrid-3way",
"er-silo",
"xsmom-silo"
],
"notes": "2026-05-12 PEAD v8 improvement from v8.4. TQQQ is allowed only through cash parking; buying_power_multiplier remains 1.0. ORB/intraday configs are intentionally untouched.",
"aliases": [
"v9.3.2b-champion"
],
"version_family": "v9",
"created_at": "2026-05-12T15:00:00",
"created_by": "ai_agent",
"status": "promoted",
"generation": 3,
"changelog": "v9.3.2b: ER days [2,8] (wider entry window)",
"parent": "return_max_long_v9.3.1_er25_xs5_fc",
"id": 1381,
"performance_summary": {},
"has_journal_entry": false,
"metadata": {
"parent": "return_max_long_v9.3_v92_er30_xs10",
"validation_snapshot": "pead_dualconv_ftb_fix_v2_probe",
"performance_fixed_capital": {
"total_return_pct": 585.97,
"sqs": 93.2,
"risk_score": 72.8,
"trades": null
}
},
"lineage": {
"parent": "return_max_long_v8.4_composed_gld_tqqq_return",
"changes": [
"Set next_open_long_earnings_mixed_inline_orderly early_failure_no_progress_days_override from 15 to 8",
"Set next_open_long_earnings_mixed_inline_orderly early_failure_no_progress_r_override from 0 to 0.05",
"Set next_open_long_bullish_raised_recovery_broad_oneoff max_position_value_pct_override to 0.92",
"Keep buying_power_multiplier at 1.0; TQQQ remains cash-parking overlay only"
]
}
}

@ -22,7 +22,10 @@ from __future__ import annotations
import datetime as dt import datetime as dt
import statistics import statistics
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Iterable, Protocol from typing import TYPE_CHECKING, Any, Iterable, Protocol
if TYPE_CHECKING:
from libs.backtest.xsmom_cache import XsmomRankCache
from libs.backtest.domain import ( from libs.backtest.domain import (
Candidate, Candidate,
@ -212,17 +215,65 @@ def evaluate_universe_gates(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _build_candidates_from_cache(
rows: list[dict[str, Any]],
decision_date: dt.date,
next_trading_date: dt.date,
engine: StrategyEngineConfig,
top_n: int,
) -> list[Candidate]:
"""Build top-N candidates from cached ranked rows (already post-quality-gate)."""
rows_sorted = sorted(rows, key=lambda r: float(r["momentum_12_1"]), reverse=True)
top = rows_sorted[:top_n]
total_ranked = len(rows_sorted)
candidates: list[Candidate] = []
for rank, row in enumerate(top, start=1):
last_bar_date = dt.date.fromisoformat(str(row["last_bar_date"]))
_assert_strictly_before(str(row["symbol"]), decision_date, [last_bar_date])
inputs = CrossSectionalMomentumInputs(
symbol=str(row["symbol"]),
decision_date=decision_date,
next_trading_date=next_trading_date,
last_bar_date=last_bar_date,
last_bar_timestamp=dt.datetime.fromisoformat(str(row["last_bar_timestamp_iso"])),
last_close=float(row["last_close"]),
momentum_12_1=float(row["momentum_12_1"]),
volatility_20d=float(row["volatility_20d"]),
avg_dollar_volume_20d=float(row["avg_dollar_volume_20d"]),
)
candidates.append(_build_candidate(inputs, engine, rank=rank, total_ranked=total_ranked))
if candidates:
logger.info(
"cross_sectional_momentum_rebalance",
decision_date=decision_date.isoformat(),
universe_scanned=total_ranked,
top_n_emitted=len(candidates),
top_score=float(top[0]["momentum_12_1"]) if top else None,
bottom_score=float(top[-1]["momentum_12_1"]) if top else None,
cache_hit=True,
)
return candidates
def build_candidates( def build_candidates(
decision_date: dt.date, decision_date: dt.date,
next_trading_date: dt.date, next_trading_date: dt.date,
universe_symbols: Iterable[str], universe_symbols: Iterable[str],
engine: StrategyEngineConfig, engine: StrategyEngineConfig,
bar_provider: BarHistoryProvider, bar_provider: BarHistoryProvider,
*,
cache: "XsmomRankCache | None" = None,
) -> list[Candidate]: ) -> list[Candidate]:
"""Emit top-N synthetic momentum candidates on rebalance days only. """Emit top-N synthetic momentum candidates on rebalance days only.
On non-rebalance days, returns []. The universe is scanned once per On non-rebalance days, returns []. The universe is scanned once per
rebalance day, ranked, and the top-N pass through. rebalance day, ranked, and the top-N pass through.
If ``cache`` is provided: on rebalance days, tries to serve from the disk
cache (populated on prior runs). Cache miss triggers the full universe scan
and saves the ranked universe for future runs.
""" """
if not getattr(engine, "xsmom_enabled", False): if not getattr(engine, "xsmom_enabled", False):
return [] return []
@ -235,17 +286,13 @@ def build_candidates(
lookback = int(getattr(engine, "xsmom_lookback_days", 252) or 252) lookback = int(getattr(engine, "xsmom_lookback_days", 252) or 252)
skip = int(getattr(engine, "xsmom_skip_days", 21) or 21) skip = int(getattr(engine, "xsmom_skip_days", 21) or 21)
top_n = int(getattr(engine, "xsmom_top_n", 20) or 20) top_n = int(getattr(engine, "xsmom_top_n", 20) or 20)
holding_days = int(getattr(engine, "xsmom_holding_days", 21) or 21)
momentum_min = float(getattr(engine, "xsmom_momentum_min", 0.0) or 0.0) momentum_min = float(getattr(engine, "xsmom_momentum_min", 0.0) or 0.0)
fetch_lookback = lookback + skip + 5 fetch_lookback = lookback + skip + 5
# First, check rebalance gate using ANY symbol's bars (they all share the
# same trading calendar). Use the first symbol with enough history.
scored: list[tuple[float, CrossSectionalMomentumInputs]] = [] scored: list[tuple[float, CrossSectionalMomentumInputs]] = []
seen: set[str] = set() seen: set[str] = set()
rebalance_checked = False rebalance_checked = False
is_rebalance = False
for raw_symbol in universe_symbols: for raw_symbol in universe_symbols:
symbol = str(raw_symbol).strip().upper() symbol = str(raw_symbol).strip().upper()
@ -270,6 +317,13 @@ def build_candidates(
rebalance_checked = True rebalance_checked = True
if not is_rebalance: if not is_rebalance:
return [] return []
# Rebalance confirmed: try cache before scanning remaining universe.
if cache is not None:
cached_rows = cache.get_date(decision_date)
if cached_rows is not None:
return _build_candidates_from_cache(
cached_rows, decision_date, next_trading_date, engine, top_n
)
last_close = float(last_bar.get("close", 0.0)) last_close = float(last_bar.get("close", 0.0))
if last_close <= 0: if last_close <= 0:
@ -312,6 +366,22 @@ def build_candidates(
scored.sort(key=lambda t: t[0], reverse=True) scored.sort(key=lambda t: t[0], reverse=True)
top = scored[:top_n] top = scored[:top_n]
# Save full ranked universe to cache (pre-top_n) for future runs.
if cache is not None and scored:
cache.save_date(decision_date, [
{
"decision_date": decision_date.isoformat(),
"symbol": inp.symbol,
"momentum_12_1": inp.momentum_12_1,
"volatility_20d": inp.volatility_20d,
"avg_dollar_volume_20d": inp.avg_dollar_volume_20d,
"last_close": inp.last_close,
"last_bar_date": inp.last_bar_date.isoformat(),
"last_bar_timestamp_iso": inp.last_bar_timestamp.isoformat(),
}
for _, inp in scored
])
candidates: list[Candidate] = [] candidates: list[Candidate] = []
for rank, (_, inputs) in enumerate(top, start=1): for rank, (_, inputs) in enumerate(top, start=1):
candidates.append(_build_candidate(inputs, engine, rank=rank, total_ranked=len(scored))) candidates.append(_build_candidate(inputs, engine, rank=rank, total_ranked=len(scored)))

@ -104,6 +104,7 @@ class SnapshotStore:
self._macro = macro_by_date or {} self._macro = macro_by_date or {}
self._price_bar_cache: dict[str, list[Any]] = {} self._price_bar_cache: dict[str, list[Any]] = {}
self._market_feature_cache: dict[tuple[str, dt.date], dict[str, Any]] = {} self._market_feature_cache: dict[tuple[str, dt.date], dict[str, Any]] = {}
self.snapshot_dir: Path | None = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Public query interface # Public query interface
@ -263,6 +264,7 @@ class SnapshotStore:
for date, rows in sliced._candidates_by_reaction_date.items() for date, rows in sliced._candidates_by_reaction_date.items()
if start_date <= date <= end_date if start_date <= date <= end_date
} }
sliced.snapshot_dir = self.snapshot_dir
return sliced return sliced
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -295,7 +297,7 @@ class SnapshotStore:
) )
snapshot_path = Path(snapshot_dir) snapshot_path = Path(snapshot_dir)
return cls._load_with_runtime_cache( store = cls._load_with_runtime_cache(
snapshot_path=snapshot_path, snapshot_path=snapshot_path,
split_names=[split_name], split_names=[split_name],
scoring_fn=scoring_fn, scoring_fn=scoring_fn,
@ -303,6 +305,8 @@ class SnapshotStore:
cls._async_load(snapshot_path, split_name, oracle_url, db_dsn, scoring_fn) cls._async_load(snapshot_path, split_name, oracle_url, db_dsn, scoring_fn)
), ),
) )
store.snapshot_dir = snapshot_path
return store
@classmethod @classmethod
def load_merged( def load_merged(
@ -326,7 +330,7 @@ class SnapshotStore:
) )
snapshot_path = Path(snapshot_dir) snapshot_path = Path(snapshot_dir)
return cls._load_with_runtime_cache( store = cls._load_with_runtime_cache(
snapshot_path=snapshot_path, snapshot_path=snapshot_path,
split_names=split_names, split_names=split_names,
scoring_fn=scoring_fn, scoring_fn=scoring_fn,
@ -334,6 +338,8 @@ class SnapshotStore:
cls._async_load_merged(snapshot_path, split_names, oracle_url, db_dsn, scoring_fn) cls._async_load_merged(snapshot_path, split_names, oracle_url, db_dsn, scoring_fn)
), ),
) )
store.snapshot_dir = snapshot_path
return store
@classmethod @classmethod
def materialize_snapshot_dir( def materialize_snapshot_dir(

@ -0,0 +1,199 @@
"""Disk-based cache for xsmom cross-sectional momentum ranked-universe results.
Caches the full pre-ranked universe per rebalance date so that re-runs with
different top_n values can skip the expensive per-symbol bar scan (900+ symbols
× 278-bar lookback) and just slice the cached ranking.
Cache key: (snapshot_fingerprint, param_hash, XSMOM_CACHE_VERSION)
Cache file: <snapshot_dir>/.runtime_cache/xsmom_v{N}__{param_hash}.parquet
The cache stores rows that have already passed all universe-quality gates
(min_price, min_adv, vol_max, momentum_min). Only top_n selection is deferred
to read time so a single cache file serves runs with different top_n values.
"""
from __future__ import annotations
import datetime as dt
import hashlib
import json
import os
from pathlib import Path
from typing import Any
from uuid import uuid4
import pyarrow as pa
import pyarrow.parquet as pq
from libs.common.logging import get_logger
logger = get_logger(__name__)
XSMOM_CACHE_VERSION = 1
# Params that determine the cached content.
# top_n is NOT here — it only slices the cached ranking.
_CACHE_KEY_PARAMS = (
"xsmom_lookback_days",
"xsmom_skip_days",
"xsmom_min_avg_dollar_volume",
"xsmom_min_price",
"xsmom_volatility_20d_max",
"xsmom_momentum_min",
)
_SCHEMA = pa.schema([
pa.field("decision_date", pa.string()),
pa.field("symbol", pa.string()),
pa.field("momentum_12_1", pa.float64()),
pa.field("volatility_20d", pa.float64()),
pa.field("avg_dollar_volume_20d", pa.float64()),
pa.field("last_close", pa.float64()),
pa.field("last_bar_date", pa.string()),
pa.field("last_bar_timestamp_iso", pa.string()),
])
def compute_snapshot_fingerprint(snapshot_dir: Path) -> str:
"""SHA-256 fingerprint of snapshot manifest + parquet files (size + mtime_ns)."""
parts: list[str] = [f"xsmom_v{XSMOM_CACHE_VERSION}"]
manifest = snapshot_dir / "manifest.json"
if manifest.exists():
s = manifest.stat()
parts.append(f"manifest:{s.st_size}:{s.st_mtime_ns}")
for pq_path in sorted(snapshot_dir.glob("*.parquet")):
s = pq_path.stat()
parts.append(f"{pq_path.name}:{s.st_size}:{s.st_mtime_ns}")
return hashlib.sha256("|".join(parts).encode()).hexdigest()[:32]
def build_param_hash(engine: Any) -> str:
"""16-char hex hash of the cache-key params from an engine config."""
params = {k: getattr(engine, k, None) for k in _CACHE_KEY_PARAMS}
raw = json.dumps(params, sort_keys=True, default=str)
return hashlib.sha256(raw.encode()).hexdigest()[:16]
class XsmomRankCache:
"""Per-snapshot-dir disk cache for xsmom rebalance-day ranked universes.
Lifecycle:
1. Construct once per backtest run (lazy, on first rebalance day).
2. Pass to build_candidates() on every call.
3. Call close() after the backtest loop to flush new rows to disk.
"""
def __init__(
self,
snapshot_dir: Path,
snapshot_fingerprint: str,
param_hash: str,
) -> None:
self._cache_dir = snapshot_dir / ".runtime_cache"
self._fingerprint = snapshot_fingerprint
self._param_hash = param_hash
self._cache_path = (
self._cache_dir
/ f"xsmom_v{XSMOM_CACHE_VERSION}__{param_hash}.parquet"
)
# None = not yet attempted; {} = loaded (possibly empty due to miss)
self._by_date: dict[str, list[dict[str, Any]]] | None = None
self._new_rows: list[dict[str, Any]] = []
# ------------------------------------------------------------------
# Internal: lazy load
# ------------------------------------------------------------------
def _ensure_loaded(self) -> None:
if self._by_date is not None:
return
if not self._cache_path.exists():
logger.info(
"xsmom_cache_miss",
reason="file_missing",
cache_file=str(self._cache_path),
)
self._by_date = {}
return
try:
table = pq.read_table(str(self._cache_path))
meta = table.schema.metadata or {}
stored_fp = (meta.get(b"xsmom_fingerprint") or b"").decode()
if stored_fp != self._fingerprint:
logger.info(
"xsmom_cache_miss",
reason="fingerprint_mismatch",
cache_file=str(self._cache_path),
)
self._by_date = {}
return
by_date: dict[str, list[dict[str, Any]]] = {}
for row in table.to_pylist():
d = str(row["decision_date"])
by_date.setdefault(d, []).append(row)
self._by_date = by_date
logger.info(
"xsmom_cache_hit",
cache_file=str(self._cache_path),
dates=len(by_date),
rows=table.num_rows,
)
except Exception as exc:
logger.warning(
"xsmom_cache_read_failed",
cache_file=str(self._cache_path),
error=str(exc),
)
self._by_date = {}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get_date(self, decision_date: dt.date) -> list[dict[str, Any]] | None:
"""Return cached ranked rows for decision_date, or None on miss."""
self._ensure_loaded()
return self._by_date.get(decision_date.isoformat())
def save_date(self, decision_date: dt.date, rows: list[dict[str, Any]]) -> None:
"""Buffer ranked rows for this rebalance date. Flushed by close()."""
self._new_rows.extend(rows)
def close(self) -> None:
"""Flush buffered rows to disk via atomic write. No-op if nothing new."""
if not self._new_rows:
return
all_rows: list[dict[str, Any]] = []
if self._by_date:
for date_rows in self._by_date.values():
all_rows.extend(date_rows)
all_rows.extend(self._new_rows)
table = pa.Table.from_pylist(all_rows, schema=_SCHEMA)
existing_meta = dict(table.schema.metadata or {})
table = table.replace_schema_metadata(
{**existing_meta, b"xsmom_fingerprint": self._fingerprint.encode()}
)
self._cache_dir.mkdir(parents=True, exist_ok=True)
_write_table_atomic(table, self._cache_path)
logger.info(
"xsmom_cache_written",
cache_file=str(self._cache_path),
total_rows=len(all_rows),
new_dates=len({r["decision_date"] for r in self._new_rows}),
)
self._new_rows = []
# ------------------------------------------------------------------
# Atomic write (mirrors libs/intraday/cache.py pattern)
# ------------------------------------------------------------------
def _write_table_atomic(table: pa.Table, path: Path) -> None:
tmp = path.with_suffix(f".{uuid4().hex}.tmp")
try:
pq.write_table(table, str(tmp), compression="snappy")
os.replace(str(tmp), str(path))
except Exception:
if tmp.exists():
tmp.unlink(missing_ok=True)
raise
Loading…
Cancel
Save