Add Form4 residual-cash sleeve and UI support

main
I Luk Kim 4 months ago
parent 86d55e01f9
commit 72681e69e5

File diff suppressed because it is too large Load Diff

@ -18,6 +18,10 @@ import tempfile
from pathlib import Path
from typing import Any
from libs.backtest.snapshots import (
resolve_snapshot,
resolve_snapshot_path as _resolve_registry_snapshot_path,
)
from libs.common.config import get_settings
from libs.common.logging import get_logger
@ -31,6 +35,8 @@ def run_backtest_session_sync(
start_date: dt.date,
end_date: dt.date,
parking_preset: str | None = None,
idle_alpha_preset: str | None = None,
form4_sleeve_preset: str | None = None,
snapshot_id_override: str | None = None,
) -> dict[str, Any]:
"""Run a single strategy using BacktestRunner (same as research backtester).
@ -40,156 +46,38 @@ def run_backtest_session_sync(
"""
from apps.backtester.run import (
BacktestRunner,
_build_store,
_build_merged_snapshot_store,
_extend_store_to_requested_window,
load_manifest,
resolve_config,
)
from libs.backtest.snapshot_store import SnapshotStore
manifest = load_manifest(config_path)
config = resolve_config(manifest)
# Apply snapshot override (e.g. for OOT periods like 2020-2021)
if snapshot_id_override:
config.dataset_snapshot_id = snapshot_id_override
config = resolve_config(manifest, snapshot_id_override=snapshot_id_override)
# Apply parking preset override (CLI --parking option)
if parking_preset:
config.risk.cash_parking_preset = parking_preset
config.risk.apply_parking_preset()
if idle_alpha_preset:
config.idle_alpha_sleeve_preset = idle_alpha_preset
config.apply_idle_alpha_sleeve_preset()
if form4_sleeve_preset:
config.form4_capture_sleeve_preset = form4_sleeve_preset
config.apply_form4_capture_sleeve_preset()
# Use merged store (train+valid+test) to cover the full date range.
# Try default parquet_dir first, fall back to data/datasets/snapshots.
from libs.common.config import get_settings
settings = get_settings()
snapshot_dir_override = None
default_path = Path(settings.parquet_dir) / config.dataset_snapshot_id
alt_path = Path("data/datasets/snapshots") / config.dataset_snapshot_id
if not default_path.exists() and alt_path.exists():
snapshot_dir_override = "data/datasets/snapshots"
store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override=snapshot_dir_override)
store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override=None)
# Slice to requested date range
store = store.slice_by_date_range(start_date, end_date)
# Extend macro data if user's end_date is beyond the snapshot's event range.
# This allows parking to run on days with no events (e.g. today, 3/30).
# Also needed so event positions can be valued up to end_date.
# Clamp to last market-closed date (don't fetch today if market hasn't closed).
import asyncio as _aio
from libs.common.time_utils import is_trading_day, to_eastern, utc_now
_oracle_url = get_settings().stock_oracle_url
now_et = to_eastern(utc_now())
_last_closed = now_et.date() if (not is_trading_day(now_et.date()) or now_et.hour >= 16) else now_et.date() - dt.timedelta(days=1)
_extend_end = min(end_date, _last_closed)
if store._macro:
max_macro = max(store._macro.keys())
if max_macro < _extend_end:
try:
extended = _aio.run(SnapshotStore._fetch_spy_macro(
date_range=(max_macro, _extend_end),
oracle_url=_oracle_url,
))
added = 0
for d, vals in extended.items():
if d > max_macro:
store._macro[d] = vals
added += 1
if added:
logger.info("backtest_macro_extended", added_days=added, new_end=max(store._macro.keys()).isoformat())
except Exception as exc:
logger.warning("backtest_macro_extend_failed", error=str(exc))
elif not store._macro and config.risk.cash_parking_enabled:
try:
store._macro = _aio.run(SnapshotStore._fetch_spy_macro(
date_range=(start_date, _extend_end),
oracle_url=_oracle_url,
))
except Exception as exc:
logger.warning("backtest_macro_fetch_failed", error=str(exc))
# Extend individual stock bars to _extend_end.
# Bars are cached to disk so Oracle API is only called once per date extension.
if store._bars:
import pickle
snapshot_path = _resolve_snapshot_path(config.dataset_snapshot_id)
cache_file = snapshot_path / f"bars_extended_{_extend_end.isoformat()}.pkl" if snapshot_path else None
# Try loading from cache first
cached = False
if cache_file and cache_file.exists():
try:
with open(cache_file, "rb") as f:
cached_bars = pickle.load(f)
added = 0
for sym, date_bars in cached_bars.items():
for d, bar in date_bars.items():
existing = store._bars.get(sym, {})
if d not in existing:
store._bars.setdefault(sym, {})[d] = bar
added += 1
if added:
logger.info("backtest_bars_loaded_from_cache", file=str(cache_file), added_bars=added)
cached = True
except Exception:
cached = False
if not cached:
symbols_to_extend = []
for sym, sym_bars in store._bars.items():
if sym_bars:
max_bar_date = max(sym_bars.keys())
if max_bar_date < _extend_end:
symbols_to_extend.append((sym, max_bar_date))
if symbols_to_extend:
fetch_start = min(d for _, d in symbols_to_extend)
total = len(symbols_to_extend)
logger.info("backtest_bars_extending", symbols=total,
fetch_range=f"{fetch_start}{_extend_end}")
# Fetch in batches with progress
batch_size = 50
all_new_bars: dict[str, dict] = {}
for batch_idx in range(0, total, batch_size):
batch = symbols_to_extend[batch_idx:batch_idx + batch_size]
batch_num = batch_idx // batch_size + 1
total_batches = (total + batch_size - 1) // batch_size
logger.info("backtest_bars_batch", batch=f"{batch_num}/{total_batches}",
symbols=len(batch))
try:
result = _aio.run(SnapshotStore._fetch_price_data(
[sym for sym, _ in batch],
(fetch_start, _extend_end),
_oracle_url,
concurrency=8,
))
for sym, new_bars in result[0].items():
all_new_bars.setdefault(sym, {}).update(new_bars)
except Exception as exc:
logger.warning("backtest_bars_batch_failed", batch=batch_num, error=str(exc))
# Merge into store
added_count = 0
new_bars_only: dict[str, dict] = {} # for cache
for sym, date_bars in all_new_bars.items():
existing_max = max(store._bars.get(sym, {}).keys()) if store._bars.get(sym) else None
for d, bar in date_bars.items():
if existing_max is None or d > existing_max:
store._bars.setdefault(sym, {})[d] = bar
new_bars_only.setdefault(sym, {})[d] = bar
added_count += 1
if added_count:
logger.info("backtest_bars_extended", symbols=len(symbols_to_extend), added_bars=added_count)
# Save to cache for next run
if cache_file and new_bars_only:
try:
cache_file.parent.mkdir(parents=True, exist_ok=True)
with open(cache_file, "wb") as f:
pickle.dump(new_bars_only, f, protocol=pickle.HIGHEST_PROTOCOL)
logger.info("backtest_bars_cached", file=str(cache_file))
except Exception as exc:
logger.warning("backtest_bars_cache_failed", error=str(exc))
store = _extend_store_to_requested_window(
store=store,
config=config,
start_date=start_date,
end_date=end_date,
snapshot_dir_override=None,
)
runner = BacktestRunner(
manifest=manifest,
@ -267,6 +155,7 @@ def _convert_from_runner(
"event_type": str(row.get("event_type", "-")),
"score": float(row.get("score", 0.0)),
"engine_id": str(row.get("engine_id", "")),
"trade_sleeve": str(row.get("trade_sleeve", "") or ""),
}
# Skip same-day KILL_SWITCH — backtest period end artifact
if trade["entry_date"] == trade["exit_date"] and trade["reason"] == "KILL_SWITCH":
@ -342,6 +231,11 @@ def _snapshot_needs_refresh(
Skips refresh if already refreshed today (marker file).
"""
resolution = resolve_snapshot(snapshot_id, snapshot_dir=snapshot_dir)
if resolution.refresh_policy == "manual_only":
return False
if not resolution.is_registry_managed and snapshot_dir is None:
return False
if _snapshot_has_required_coverage(snapshot_id=snapshot_id, end_date=end_date, snapshot_dir=snapshot_dir):
return False
# Check if we already attempted refresh today (avoid repeated pipeline runs)
@ -408,31 +302,34 @@ def _resolve_snapshot_path(
snapshot_dir: str | None = None,
) -> Path | None:
"""Resolve the on-disk snapshot directory using the same fallback order as the runner."""
candidates: list[Path] = []
if snapshot_dir is not None:
candidates.append(Path(snapshot_dir) / snapshot_id)
else:
settings = get_settings()
candidates.append(Path(settings.parquet_dir) / snapshot_id)
candidates.append(Path("data/datasets/snapshots") / snapshot_id)
seen: set[Path] = set()
for candidate in candidates:
candidate = candidate.resolve()
if candidate in seen:
continue
seen.add(candidate)
if candidate.exists():
return candidate
return None
return _resolve_registry_snapshot_path(snapshot_id, snapshot_dir=snapshot_dir)
async def _refresh_snapshot(
snapshot_id: str,
universe_profile: str | None,
console=None,
*,
manual: bool = False,
) -> None:
"""Re-run pipeline steps and re-export the snapshot."""
resolution = resolve_snapshot(snapshot_id)
if not resolution.is_registry_managed:
raise RuntimeError(
f"Snapshot '{snapshot_id}' is not registry-managed; phase-1 refresh only supports canonical snapshots."
)
if resolution.refresh_policy == "manual_only" and not manual:
if console:
console.print(
f"\n[bold yellow]Snapshot '{snapshot_id}' resolves to frozen canonical "
f"'{resolution.canonical_snapshot_id}' — skipping auto-refresh.[/]"
)
return
if console and resolution.requested_snapshot_id != resolution.canonical_snapshot_id:
console.print(
f"\n[bold cyan]Resolved snapshot:[/] {resolution.requested_snapshot_id} "
f"{resolution.canonical_snapshot_id}"
)
if console:
console.print("\n[bold yellow]Snapshot stale — refreshing pipeline...[/]")
@ -483,26 +380,17 @@ async def _refresh_snapshot(
if console:
console.print(f" [yellow]Label generator skipped: {exc}[/]")
# Step 2: Re-export snapshot
# Step 2: Rebuild canonical snapshot
if console:
console.print(" [dim]4/4 Exporting snapshot...[/]")
try:
from libs.db.session import get_session
from libs.export.snapshot_export import export_dataset_snapshot
async with get_session() as session:
await export_dataset_snapshot(
session=session,
snapshot_id=snapshot_id,
split_policy="temporal_70_15_15",
output_dir="data/datasets/snapshots",
feature_versions=["market_v1", "event_v1"],
universe_profile=universe_profile,
)
from libs.export.canonical_snapshots import build_canonical_snapshot
await build_canonical_snapshot(snapshot_id, manual=manual)
if console:
console.print(" [green]Snapshot refreshed.[/]")
# Write marker to avoid re-refreshing today
snapshot_path = _resolve_snapshot_path(snapshot_id)
snapshot_path = _resolve_snapshot_path(resolution.canonical_snapshot_id)
if snapshot_path:
(snapshot_path / ".last_refresh").write_text(dt.date.today().isoformat())
except Exception as exc:
@ -520,6 +408,7 @@ def run_backtest(
oracle_url: str,
console=None,
parking_preset: str | None = None,
idle_alpha_preset: str | None = None,
snapshot_id_override: str | None = None,
auto_refresh: bool = True,
) -> list[dict[str, Any]]:
@ -552,8 +441,8 @@ def run_backtest(
for config_path in configs:
from apps.backtester.run import load_manifest, resolve_config
manifest = load_manifest(config_path)
config = resolve_config(manifest)
snapshot_id = config.dataset_snapshot_id
config = resolve_config(manifest, snapshot_id_override=snapshot_id_override)
snapshot_id = config.requested_snapshot_id or config.dataset_snapshot_id
if auto_refresh and _snapshot_needs_refresh(snapshot_id, end_date):
universe_profile = None
@ -567,7 +456,7 @@ def run_backtest(
if console:
console.print(f"\n[bold yellow]Snapshot '{snapshot_id}' is stale — refreshing...[/]")
try:
asyncio.run(_refresh_snapshot(snapshot_id, universe_profile, console=console))
asyncio.run(_refresh_snapshot(snapshot_id, universe_profile, console=console, manual=False))
except Exception:
if _snapshot_has_required_coverage(snapshot_id, end_date):
if console:
@ -589,6 +478,7 @@ def run_backtest(
start_date=start_date,
end_date=end_date,
parking_preset=parking_preset,
idle_alpha_preset=idle_alpha_preset,
snapshot_id_override=snapshot_id_override,
)

@ -6,6 +6,7 @@ Order execution (HOW to execute) is done via Alpaca Paper Trading API.
from __future__ import annotations
import datetime as dt
import json
import math
import time
from dataclasses import dataclass, field
@ -21,7 +22,7 @@ from libs.backtest.domain import (
PlannedOrder,
PositionStatus,
)
from libs.backtest.execution import simulate_exit, update_trailing_stop
from libs.backtest.execution import simulate_exit, simulate_scheduled_open_exit, update_trailing_stop
from libs.backtest.manifests import load_manifest, resolve_config
from libs.backtest.selector import select_candidates
from libs.common.logging import get_logger
@ -92,6 +93,12 @@ class PaperTradingEngine:
if session.parking_preset:
self._config.risk.cash_parking_preset = session.parking_preset
self._config.risk.apply_parking_preset()
if session.idle_alpha_preset:
self._config.idle_alpha_sleeve_preset = session.idle_alpha_preset
self._config.apply_idle_alpha_sleeve_preset()
if session.form4_sleeve_preset:
self._config.form4_capture_sleeve_preset = session.form4_sleeve_preset
self._config.apply_form4_capture_sleeve_preset()
# Shared attention filtering service (matches BacktestRunner)
from libs.backtest.attention import AttentionFilterService
@ -100,6 +107,184 @@ class PaperTradingEngine:
oracle_url=oracle_url,
scoring_model=self._config.signal.scoring_model,
)
self._capital_bucket_specs: dict[str, float] = {}
get_strategy_engines = getattr(self._config, "get_strategy_engines", None)
strategy_engines = get_strategy_engines() if callable(get_strategy_engines) else []
for engine in strategy_engines or []:
bucket_id = getattr(engine, "capital_bucket_id", None)
allocation = getattr(engine, "capital_bucket_allocation_pct", None)
if not bucket_id or allocation is None or allocation <= 0:
continue
self._capital_bucket_specs[bucket_id] = max(
self._capital_bucket_specs.get(bucket_id, 0.0),
float(allocation),
)
def _get_candidate_capital_bucket_id(self, candidate: Candidate) -> str | None:
return candidate.engine_capital_bucket_id
def _get_strategy_state_capital_bucket_id(self, state: StrategyStateRow) -> str | None:
try:
payload = json.loads(state.candidate_json)
except Exception:
return None
bucket_id = payload.get("engine_capital_bucket_id") or payload.get("capital_bucket_id")
if not bucket_id:
return None
return str(bucket_id)
def _active_capital_bucket_ids_for_candidates(
self,
candidates: list[Candidate],
strategy_states: dict[str, StrategyStateRow],
) -> set[str]:
active_bucket_ids = {
bucket_id
for bucket_id in (
self._get_candidate_capital_bucket_id(candidate)
for candidate in candidates
)
if bucket_id
}
for state in strategy_states.values():
bucket_id = self._get_strategy_state_capital_bucket_id(state)
if bucket_id:
active_bucket_ids.add(bucket_id)
return active_bucket_ids
def _capital_bucket_notional(
self,
bucket_id: str,
alpaca_positions: list[Position],
strategy_states: dict[str, StrategyStateRow],
) -> float:
notional = 0.0
for position in alpaca_positions:
state = strategy_states.get(position.symbol)
if state is None or self._get_strategy_state_capital_bucket_id(state) != bucket_id:
continue
notional += abs(float(position.market_value))
return notional
def _capital_bucket_entry_cost(
self,
bucket_id: str,
alpaca_positions: list[Position],
strategy_states: dict[str, StrategyStateRow],
) -> float:
entry_cost = 0.0
for position in alpaca_positions:
state = strategy_states.get(position.symbol)
if state is None or self._get_strategy_state_capital_bucket_id(state) != bucket_id:
continue
entry_cost += abs(float(position.avg_entry_price) * float(position.qty))
return entry_cost
def _capital_bucket_realized_pnl(self, session_id: str, bucket_id: str) -> float:
realized = 0.0
for trade in self._state.list_trades(session_id):
if trade.get("capital_bucket_id") != bucket_id:
continue
realized += float(trade.get("net_pnl") or 0.0)
return realized
def _capital_bucket_equity(
self,
bucket_id: str,
session_id: str,
alpaca_positions: list[Position],
strategy_states: dict[str, StrategyStateRow],
) -> float:
allocation = self._capital_bucket_specs.get(bucket_id)
if allocation is None:
return 0.0
initial_bucket_equity = self._session.initial_equity * allocation
market_value = self._capital_bucket_notional(bucket_id, alpaca_positions, strategy_states)
entry_cost = self._capital_bucket_entry_cost(bucket_id, alpaca_positions, strategy_states)
unrealized = market_value - entry_cost
return max(
0.0,
initial_bucket_equity
+ self._capital_bucket_realized_pnl(session_id, bucket_id)
+ unrealized,
)
def _capital_bucket_cash_available(
self,
bucket_id: str,
session_id: str,
alpaca_positions: list[Position],
strategy_states: dict[str, StrategyStateRow],
) -> float:
market_value = self._capital_bucket_notional(bucket_id, alpaca_positions, strategy_states)
return max(
0.0,
self._capital_bucket_equity(bucket_id, session_id, alpaca_positions, strategy_states)
- market_value,
)
def _adjust_portfolio_state_for_candidate(
self,
*,
session_id: str,
candidate: Candidate,
portfolio_state: DailyPortfolioState,
active_bucket_ids: set[str],
alpaca_positions: list[Position],
strategy_states: dict[str, StrategyStateRow],
) -> DailyPortfolioState:
if not self._capital_bucket_specs or portfolio_state.cash_available <= 0:
return portfolio_state
configured_bucket_ids = set(self._capital_bucket_specs)
candidate_bucket = self._get_candidate_capital_bucket_id(candidate)
relevant_bucket_ids = configured_bucket_ids & active_bucket_ids
if candidate_bucket and candidate_bucket in configured_bucket_ids:
relevant_bucket_ids.add(candidate_bucket)
if not relevant_bucket_ids:
return portfolio_state
bucket_cash_available = {
bucket_id: self._capital_bucket_cash_available(
bucket_id, session_id, alpaca_positions, strategy_states
)
for bucket_id in relevant_bucket_ids
}
bucket_equity = {
bucket_id: self._capital_bucket_equity(
bucket_id, session_id, alpaca_positions, strategy_states
)
for bucket_id in relevant_bucket_ids
}
sizing_equity = portfolio_state.sizing_equity or portfolio_state.equity
if candidate_bucket and candidate_bucket in relevant_bucket_ids:
adjusted_cash = min(
portfolio_state.cash_available,
bucket_cash_available[candidate_bucket],
)
adjusted_sizing_equity = bucket_equity[candidate_bucket]
else:
adjusted_cash = max(
0.0,
portfolio_state.cash_available - sum(bucket_cash_available.values()),
)
adjusted_sizing_equity = max(
0.0,
sizing_equity - sum(bucket_equity.values()),
)
if (
math.isclose(adjusted_cash, portfolio_state.cash_available, rel_tol=0.0, abs_tol=1e-9)
and math.isclose(adjusted_sizing_equity, sizing_equity, rel_tol=0.0, abs_tol=1e-9)
):
return portfolio_state
return portfolio_state.model_copy(
update={
"cash_available": adjusted_cash,
"sizing_equity": adjusted_sizing_equity,
}
)
# ------------------------------------------------------------------ #
# Reconciliation & safety
@ -164,6 +349,8 @@ class PaperTradingEngine:
self._state.record_trade(
session_id=session_id,
symbol=sym,
engine_id=ss.engine_id,
capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss),
entry_date=ss.entry_date,
exit_date=today.isoformat(),
entry_price=None,
@ -341,6 +528,8 @@ class PaperTradingEngine:
self._state.record_trade(
session_id=session_id,
symbol=sym,
engine_id=ss.engine_id,
capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss),
entry_date=ss.entry_date,
exit_date=today.isoformat(),
entry_price=alpaca_pos.avg_entry_price,
@ -397,13 +586,8 @@ class PaperTradingEngine:
# Reset sold_today flag from yesterday
if parking_st and parking_st.get("sold_today", 0):
self._state.update_parking_gate_state(session_id, sold_today=0)
# Accrue SGOV interest
if parking_st and parking_st["symbol"] == "SGOV":
daily_rate = self._config.risk.cash_parking_sgov_annual_rate / 252
new_value = parking_st["entry_value"] * (1 + daily_rate)
self._state.update_parking_sgov_value(session_id, new_value)
# Update peak price for trailing stop / top-up
elif parking_st and parking_st["symbol"] != "SGOV":
if parking_st and parking_st["symbol"] != "SGOV":
sym = parking_st["symbol"]
bars = self._broker.get_latest_bars([sym])
if sym in bars:
@ -493,6 +677,7 @@ class PaperTradingEngine:
p.symbol for p in alpaca_positions_after_exits
if p.symbol in strategy_states_after_exits
}
engine_batches: list[tuple[Any, list[Candidate]]] = []
for engine_cfg in engines:
prelimit = self._config.signal.max_candidates_per_day
if self._attention_service.engine_requires_attention(engine_cfg):
@ -515,12 +700,30 @@ class PaperTradingEngine:
if engine_cfg.residual_reserve_selected and engine_candidates:
reserved_event_ids.update(c.event_id for c in engine_candidates)
reserved_symbols.update(c.symbol.upper() for c in engine_candidates)
engine_risk_used = engine_daily_risk_used.get(engine_cfg.engine_id, 0.0)
engine_batches.append((engine_cfg, engine_candidates))
active_bucket_ids = self._active_capital_bucket_ids_for_candidates(
[
candidate
for _, batch_candidates in engine_batches
for candidate in batch_candidates
],
strategy_states_after_exits,
)
for engine_cfg, engine_candidates in engine_batches:
for candidate in engine_candidates:
plan = build_planned_order(
engine_risk_used = engine_daily_risk_used.get(engine_cfg.engine_id, 0.0)
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
session_id=session_id,
candidate=candidate,
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
alpaca_positions=alpaca_positions_after_exits,
strategy_states=strategy_states_after_exits,
)
plan = build_planned_order(
candidate=candidate,
portfolio_state=candidate_portfolio_state,
open_positions=open_positions,
config=self._config,
cooldown_remaining=session_st.cooldown_remaining,
@ -543,10 +746,19 @@ class PaperTradingEngine:
if self._parking_liquidate_for_event(session_id, today, needed):
account = self._broker.get_account()
_ap2 = self._broker.list_positions()
alpaca_positions_after_exits = _ap2
portfolio_state = self._build_portfolio_state(account, _ap2, today)
plan = build_planned_order(
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
session_id=session_id,
candidate=candidate,
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
alpaca_positions=_ap2,
strategy_states=strategy_states_after_exits,
)
plan = build_planned_order(
candidate=candidate,
portfolio_state=candidate_portfolio_state,
open_positions=open_positions,
config=self._config,
cooldown_remaining=session_st.cooldown_remaining,
@ -620,7 +832,8 @@ class PaperTradingEngine:
),
)
trade_risk = portfolio_state.equity * (
trade_risk_state = candidate_portfolio_state.sizing_equity or candidate_portfolio_state.equity
trade_risk = trade_risk_state * (
candidate.engine_per_trade_risk_pct
or self._config.risk.per_trade_risk_pct
)
@ -628,6 +841,11 @@ class PaperTradingEngine:
session_st.daily_new_risk_used += trade_risk
# Refresh portfolio state after each entry
alpaca_positions_after_exits = self._broker.list_positions()
strategy_states_after_exits = {
ss.symbol: ss
for ss in self._state.get_open_strategy_states(session_id)
}
open_positions = self._to_open_positions(
alpaca_positions_after_exits, strategy_states_after_exits
)
@ -637,6 +855,7 @@ class PaperTradingEngine:
portfolio_state = DailyPortfolioState(
date=portfolio_state.date,
equity=portfolio_state.equity,
sizing_equity=portfolio_state.sizing_equity,
cash_available=max(
0.0,
portfolio_state.cash_available - plan.entry_price_limit * plan.shares,
@ -673,10 +892,22 @@ class PaperTradingEngine:
excluded_event_ids={ss.event_id for ss in strategy_states_after_exits.values()},
excluded_symbols={p.symbol for p in alpaca_positions_after_exits if p.symbol in strategy_states_after_exits},
)
active_bucket_ids = self._active_capital_bucket_ids_for_candidates(
list(all_candidates),
strategy_states_after_exits,
)
for candidate in all_candidates:
plan = build_planned_order(
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
session_id=session_id,
candidate=candidate,
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
alpaca_positions=alpaca_positions_after_exits,
strategy_states=strategy_states_after_exits,
)
plan = build_planned_order(
candidate=candidate,
portfolio_state=candidate_portfolio_state,
open_positions=open_positions,
config=self._config,
cooldown_remaining=session_st.cooldown_remaining,
@ -998,32 +1229,28 @@ class PaperTradingEngine:
# --- Execute sell ---
sym = parking_st["symbol"]
qty = parking_st["qty"]
entry_price_for_pnl = parking_st.get("sgov_entry_value", 0) or parking_st["avg_price"]
logger.info("parking_sell", symbol=sym, qty=qty, reason=f"gate→{target}")
try:
if sym != "SGOV" and qty > 0:
if qty > 0:
self._broker.close_position(sym, qty=qty)
time.sleep(1)
self._state.close_parking_state(session_id)
# Persist gate_in_sgov state for next parking buy
# (store in a separate row or use session-level tracking)
# Record trade
bars = self._broker.get_latest_bars([sym]) if sym != "SGOV" else {}
bars = self._broker.get_latest_bars([sym])
exit_price = bars[sym].close if sym in bars else parking_st["avg_price"]
if sym == "SGOV":
exit_price = parking_st["entry_value"] # includes accrued interest
entry_price_for_pnl = parking_st.get("sgov_entry_value", 0) or parking_st["avg_price"]
self._state.record_trade(
session_id=session_id,
symbol=sym,
engine_id=None,
capital_bucket_id=None,
entry_date=parking_st["entry_date"],
exit_date=today.isoformat(),
entry_price=entry_price_for_pnl if sym == "SGOV" else parking_st["avg_price"],
entry_price=parking_st["avg_price"],
exit_price=exit_price,
exit_reason="PARKING",
shares=qty,
net_pnl=(exit_price - entry_price_for_pnl) * qty if sym == "SGOV" else (exit_price - parking_st["avg_price"]) * qty,
net_pnl=(exit_price - parking_st["avg_price"]) * qty,
r_multiple=0.0,
holding_days=(today - dt.date.fromisoformat(parking_st["entry_date"])).days,
)
@ -1039,8 +1266,7 @@ class PaperTradingEngine:
"""Release parking cash to fund an event entry that has insufficient cash.
Mirrors BacktestRunner._liquidate_parking_for_cash().
- SGOV (virtual): reduces entry_value in DB; cash becomes available immediately.
- QQQM/QQQ/SPY (real): sells shares via broker; waits 1 s for fill.
Sells shares via broker; waits 1 s for fill.
Returns True if any cash was freed.
"""
parking_st = self._state.get_parking_state(session_id)
@ -1049,23 +1275,7 @@ class PaperTradingEngine:
sym = parking_st["symbol"]
if sym == "SGOV":
current_value = float(parking_st.get("entry_value", 0.0))
release = min(current_value, needed)
if release < 1.0:
return False
new_value = current_value - release
if new_value < 1.0:
self._state.close_parking_state(session_id)
else:
self._state.update_parking_sgov_value(session_id, new_value)
logger.info(
"parking_partial_release_for_event",
symbol="SGOV", released=round(release, 2), remaining=round(new_value, 2),
)
return True
# Real broker position (QQQM / QQQ / SPY)
# Broker position (SGOV / QQQM / QQQ / SPY)
qty = parking_st.get("qty", 0)
if qty <= 0:
return False
@ -1112,12 +1322,7 @@ class PaperTradingEngine:
if parking_st is None:
return 0.0, 0.0
sym = parking_st["symbol"]
if sym == "SGOV":
# Virtual position — no actual broker share
current_value = float(parking_st.get("entry_value", 0.0))
original = float(parking_st.get("sgov_entry_value", 0) or current_value)
return current_value, current_value - original
# Real broker position (QQQ, SPY, TQQQ, QQQM …)
# Broker position (SGOV / QQQ / SPY / TQQQ / QQQM …)
qty = parking_st.get("qty", 0)
avg = float(parking_st.get("avg_price", 0))
if qty <= 0:
@ -1172,8 +1377,6 @@ class PaperTradingEngine:
# --- Top-up existing parking ---
if parking_st is not None:
sym = parking_st["symbol"]
if sym == "SGOV":
return # SGOV top-up handled via interest accrual
# Check top-up conditions
topup_dd = getattr(risk, "cash_parking_topup_max_peak_drawdown_pct", 0)
if topup_dd > 0:
@ -1201,20 +1404,7 @@ class PaperTradingEngine:
# --- New parking position ---
target = self._parking_evaluate_gate(today)
if target == "sgov":
session_equity, session_cash = self._session_cash(session_id)
reserve = session_equity * risk.cash_parking_reserve_pct
investable = max(0.0, session_cash - reserve)
if investable > 100:
self._state.save_parking_state(
session_id, "SGOV", today, 1, investable, investable,
gate_in_sgov=1, committed_target="sgov",
sgov_entry_value=investable,
)
logger.info("parking_buy_sgov", amount=round(investable, 2))
return
# QQQ/SPY/QQQM: buy through broker
# SGOV/QQQ/SPY/QQQM: buy through broker
sym = target.upper()
session_equity, session_cash = self._session_cash(session_id)
reserve = session_equity * risk.cash_parking_reserve_pct
@ -1241,7 +1431,7 @@ class PaperTradingEngine:
avg_price = filled.filled_avg_price
self._state.save_parking_state(
session_id, sym, today, qty, avg_price, avg_price * qty,
peak_price=avg_price, gate_in_sgov=0,
peak_price=avg_price, gate_in_sgov=1 if target == "sgov" else 0,
committed_target=target,
)
logger.info("parking_filled", symbol=sym, qty=qty, price=round(avg_price, 2))
@ -1284,12 +1474,12 @@ class PaperTradingEngine:
all_rows = await self._detector.get_candidates_for_date(
today, self._config, convention="reaction_close"
)
same_day_rows = [r for r in all_rows if self._is_same_day_event(r)]
macro_data = await self._fetch_macro(today)
entries, rejected = await self._process_entries(
today, same_day_rows, account, alpaca_positions, strategy_states,
today, all_rows, account, alpaca_positions, strategy_states,
session_st, macro_data, self._broker.submit_moc_buy,
entry_timing="reaction_close",
)
session_st.last_processed_date = today.isoformat()
@ -1300,7 +1490,7 @@ class PaperTradingEngine:
logger.info(
"paper_engine_reaction_close_done",
date=today.isoformat(),
same_day_candidates=len(same_day_rows),
candidates=len(all_rows),
entries=len(entries),
rejected=len(rejected),
)
@ -1310,7 +1500,7 @@ class PaperTradingEngine:
"phase": phase,
"entries": entries,
"rejected": rejected,
"candidates_detected": len(same_day_rows),
"candidates_detected": len(all_rows),
"account": {"equity": session_eq, "cash": session_ca,
"market_value": account.long_market_value},
}
@ -1369,23 +1559,36 @@ class PaperTradingEngine:
if self._config.risk.cash_parking_enabled:
parking_sold_today = self._parking_check_and_sell(session_id, today)
# Entry: after-close 이벤트만 — next_open_after_reaction_close convention
# Entry: all events with entry_date == today, across both conventions.
# Mirrors BacktestRunner: next_open engines call get_candidates_for_date(date)
# which returns ALL rows with execution_date==date regardless of entry_convention.
#
# Exclusion: same-day events with reaction_close convention are excluded here
# because in the Parquet their execution_date is reaction_date+1 (next_open_after
# _reaction_close entry), not reaction_date. Their next_open entry will appear
# tomorrow with entry_convention='next_open_after_reaction_close'.
# ENB (after-close with reaction_close convention, entry_date=reaction_date) is
# correctly included because event_date != reaction_date (not same_day).
all_rows = await self._detector.get_candidates_for_date(
today, self._config, convention="next_open_after_reaction_close"
today, self._config, convention=None
)
after_close_rows = [r for r in all_rows if not self._is_same_day_event(r)]
next_open_rows = [
r for r in all_rows
if not (self._is_same_day_event(r) and r.get("entry_convention") == "reaction_close")
]
macro_data = await self._fetch_macro(today)
entries, rejected = await self._process_entries(
today, after_close_rows, account, alpaca_positions, strategy_states,
today, next_open_rows, account, alpaca_positions, strategy_states,
session_st, macro_data, self._broker.submit_market_buy,
entry_timing="next_open",
)
# CASH PARKING: buy with remaining idle cash after entries
if self._config.risk.cash_parking_enabled and not parking_sold_today:
self._parking_buy(session_id, today)
summary = self._finalize_day(today, session_st, exits, entries, rejected, len(all_rows))
summary = self._finalize_day(today, session_st, exits, entries, rejected, len(next_open_rows))
summary["phase"] = phase
self._state.mark_phase_processed(session_id, today, phase)
return summary
@ -1451,6 +1654,8 @@ class PaperTradingEngine:
self._state.record_trade(
session_id=session_id,
symbol=pos.symbol,
engine_id=ss.engine_id,
capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss),
entry_date=ss.entry_date,
exit_date=dt.date.today().isoformat(),
entry_price=pos.avg_entry_price,
@ -1503,7 +1708,8 @@ class PaperTradingEngine:
return exits
bar_start = bar_date - dt.timedelta(days=30)
bars_by_symbol = self._broker.get_bars_as_dict(held_symbols, bar_start, bar_date)
# Fetch up to today so NO_PROGRESS / EARLY_FAILURE can execute at today's open.
bars_by_symbol = self._broker.get_bars_as_dict(held_symbols, bar_start, today)
for alpaca_pos in alpaca_positions:
sym = alpaca_pos.symbol
@ -1548,6 +1754,47 @@ class PaperTradingEngine:
ss.peak_price = open_pos.peak_price
filled_trade = simulate_exit(open_pos, bar, effective_exec, bar_date)
# NO_PROGRESS / EARLY_FAILURE: check yesterday's close, execute at today's open.
# Mirrors BacktestRunner._evaluate_pending_open_exit + _process_pending_open_exits.
if filled_trade is None:
close_val = bar.get("close")
if close_val is not None:
close_val = float(close_val)
np_days = effective_exec.early_failure_no_progress_days
np_r = effective_exec.early_failure_no_progress_r
scheduled_reason: str | None = None
# NO_PROGRESS: not enough progress by day N
if (
np_days is not None and np_r is not None
and ss.days_held == np_days
and open_pos.status.value != "partial"
):
initial_r = abs(alpaca_pos.avg_entry_price - ss.current_stop)
progress_price = alpaca_pos.avg_entry_price + initial_r * np_r
if close_val < progress_price:
scheduled_reason = "NO_PROGRESS"
# EARLY_FAILURE: day-1 close below both entry and reaction close
if scheduled_reason is None and (
effective_exec.early_failure_close_below_entry_and_reaction_close
and ss.days_held == 1
and close_val < alpaca_pos.avg_entry_price
):
reaction_close = float(open_pos.plan.candidate.features.get("event_close") or close_val)
if close_val < reaction_close:
scheduled_reason = "EARLY_FAILURE"
if scheduled_reason is not None:
today_bar = bars_by_symbol.get(sym, {}).get(today)
if today_bar is not None:
filled_trade = simulate_scheduled_open_exit(
position=open_pos,
bar=today_bar,
config=effective_exec,
current_date=today,
reason=scheduled_reason,
fraction=float(effective_exec.early_failure_no_progress_fraction or 1.0),
)
if filled_trade is not None:
is_partial = filled_trade.shares < alpaca_pos.qty
try:
@ -1574,6 +1821,8 @@ class PaperTradingEngine:
self._state.close_strategy_state(session_id, sym)
self._state.record_trade(
session_id=session_id, symbol=sym,
engine_id=ss.engine_id,
capital_bucket_id=self._get_strategy_state_capital_bucket_id(ss),
entry_date=ss.entry_date, exit_date=today.isoformat(),
entry_price=alpaca_pos.avg_entry_price, exit_price=filled_trade.exit_price,
exit_reason=filled_trade.exit_reason.value, shares=filled_trade.shares,
@ -1614,8 +1863,14 @@ class PaperTradingEngine:
session_st: Any,
macro_data: dict[str, Any],
order_fn: Any,
entry_timing: str | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""후보군에 대해 진입 판단 + 주문 제출. order_fn = submit_market_buy | submit_moc_buy."""
"""후보군에 대해 진입 판단 + 주문 제출. order_fn = submit_market_buy | submit_moc_buy.
entry_timing: 'reaction_close' or 'next_open'. When set, only engines with
matching entry_timing_policy are used. Mirrors BacktestRunner's per-engine
get_candidates_for_date vs get_candidates_for_reaction_date split.
"""
session_id = self._session.session_id
entries: list[dict[str, Any]] = []
rejected: list[dict[str, Any]] = []
@ -1630,6 +1885,19 @@ class PaperTradingEngine:
if not self._state.has_processed_event(session_id, str(r.get("event_id", "")))
]
# Inject macro values from _fetch_macro() into candidate rows.
# EventDetector (PostgreSQL) rows lack macro_vix/macro_hy_spread; the
# Parquet snapshot pre-embeds them. Without this injection, any engine
# with macro_vix_max set will reject all candidates (None fails the check).
_macro_vix = macro_data.get("VIXCLS")
_macro_hy = macro_data.get("BAMLH0A0HYM2")
if _macro_vix is not None or _macro_hy is not None:
for row in candidate_rows:
if _macro_vix is not None and row.get("macro_vix") is None:
row["macro_vix"] = _macro_vix
if _macro_hy is not None and row.get("macro_hy_spread") is None:
row["macro_hy_spread"] = _macro_hy
open_positions = self._to_open_positions(alpaca_positions, strategy_states)
portfolio_state = self._build_portfolio_state(account, alpaca_positions, today)
engines = self._config.get_active_strategy_engines()
@ -1658,8 +1926,14 @@ class PaperTradingEngine:
engine_list = engines if engines else [None]
reserved_event_ids: set[str] = {ss.event_id for ss in strategy_states.values()}
reserved_symbols: set[str] = {p.symbol for p in alpaca_positions if p.symbol in strategy_states}
candidate_batches: list[tuple[Any | None, list[Candidate]]] = []
for engine_cfg in engine_list:
if engine_cfg is not None:
# Skip engines that don't match the requested entry timing policy.
# Mirrors BacktestRunner: reaction_close engines use get_candidates_for_reaction_date,
# next_open engines use get_candidates_for_date.
if entry_timing is not None and engine_cfg.entry_timing_policy != entry_timing:
continue
prelimit = self._config.signal.max_candidates_per_day
if self._attention_service.engine_requires_attention(engine_cfg):
prelimit = max(prelimit * 5, prelimit)
@ -1680,7 +1954,6 @@ class PaperTradingEngine:
if engine_cfg.residual_reserve_selected and engine_candidates:
reserved_event_ids.update(c.event_id for c in engine_candidates)
reserved_symbols.update(c.symbol.upper() for c in engine_candidates)
engine_risk_used = engine_daily_risk_used.get(engine_cfg.engine_id, 0.0)
else:
engine_candidates = select_candidates(
raw_rows=candidate_rows,
@ -1690,11 +1963,33 @@ class PaperTradingEngine:
excluded_event_ids=reserved_event_ids,
excluded_symbols=reserved_symbols,
)
engine_risk_used = 0.0
candidate_batches.append((engine_cfg, engine_candidates))
active_bucket_ids = self._active_capital_bucket_ids_for_candidates(
[
candidate
for _, batch_candidates in candidate_batches
for candidate in batch_candidates
],
strategy_states,
)
for engine_cfg, engine_candidates in candidate_batches:
for candidate in engine_candidates:
engine_risk_used = (
engine_daily_risk_used.get(engine_cfg.engine_id, 0.0)
if engine_cfg is not None
else 0.0
)
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
session_id=session_id,
candidate=candidate,
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
alpaca_positions=alpaca_positions,
strategy_states=strategy_states,
)
plan = build_planned_order(
candidate=candidate, portfolio_state=portfolio_state,
candidate=candidate, portfolio_state=candidate_portfolio_state,
open_positions=open_positions, config=self._config,
cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data,
@ -1711,9 +2006,18 @@ class PaperTradingEngine:
if self._parking_liquidate_for_event(session_id, today, needed):
account = self._broker.get_account()
_ap2 = self._broker.list_positions()
alpaca_positions = _ap2
portfolio_state = self._build_portfolio_state(account, _ap2, today)
candidate_portfolio_state = self._adjust_portfolio_state_for_candidate(
session_id=session_id,
candidate=candidate,
portfolio_state=portfolio_state,
active_bucket_ids=active_bucket_ids,
alpaca_positions=_ap2,
strategy_states=strategy_states,
)
plan = build_planned_order(
candidate=candidate, portfolio_state=portfolio_state,
candidate=candidate, portfolio_state=candidate_portfolio_state,
open_positions=open_positions, config=self._config,
cooldown_remaining=session_st.cooldown_remaining,
macro_data=macro_data,
@ -1767,17 +2071,24 @@ class PaperTradingEngine:
status="open",
),
)
trade_risk = portfolio_state.equity * (
trade_risk_state = candidate_portfolio_state.sizing_equity or candidate_portfolio_state.equity
trade_risk = trade_risk_state * (
candidate.engine_per_trade_risk_pct or self._config.risk.per_trade_risk_pct
)
if engine_cfg:
engine_daily_risk_used[engine_cfg.engine_id] = engine_risk_used + trade_risk
session_st.daily_new_risk_used += trade_risk
alpaca_positions = self._broker.list_positions()
strategy_states = {
ss.symbol: ss
for ss in self._state.get_open_strategy_states(session_id)
}
open_positions = self._to_open_positions(alpaca_positions, strategy_states)
open_positions.append(self._virtual_open_position(candidate, plan, today))
portfolio_state = DailyPortfolioState(
date=portfolio_state.date, equity=portfolio_state.equity,
sizing_equity=portfolio_state.sizing_equity,
cash_available=max(0.0, portfolio_state.cash_available - plan.entry_price_limit * plan.shares),
gross_exposure=portfolio_state.gross_exposure + plan.entry_price_limit * plan.shares,
net_exposure=portfolio_state.net_exposure + plan.entry_price_limit * plan.shares,
@ -1979,6 +2290,7 @@ class PaperTradingEngine:
return DailyPortfolioState(
date=date,
equity=session_equity,
sizing_equity=session_equity,
cash_available=session_cash,
gross_exposure=session_market_value,
net_exposure=session_market_value,

@ -12,7 +12,9 @@ CREATE TABLE IF NOT EXISTS sessions (
initial_equity REAL NOT NULL,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
parking_preset TEXT
parking_preset TEXT,
idle_alpha_preset TEXT,
form4_sleeve_preset TEXT
);
CREATE TABLE IF NOT EXISTS strategy_states (
@ -57,6 +59,8 @@ CREATE TABLE IF NOT EXISTS trades (
trade_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
symbol TEXT NOT NULL,
engine_id TEXT,
capital_bucket_id TEXT,
entry_date TEXT,
exit_date TEXT,
entry_price REAL,
@ -124,6 +128,16 @@ def create_schema(db_path: str | Path) -> None:
conn.commit()
except Exception:
pass # column already exists
try:
conn.execute("ALTER TABLE sessions ADD COLUMN idle_alpha_preset TEXT")
conn.commit()
except Exception:
pass # column already exists
try:
conn.execute("ALTER TABLE sessions ADD COLUMN form4_sleeve_preset TEXT")
conn.commit()
except Exception:
pass # column already exists
# Migration: add parking_state columns for v2 (top-up, hysteresis, etc.)
for col, dtype, default in [
("peak_price", "REAL", "0"),
@ -139,5 +153,14 @@ def create_schema(db_path: str | Path) -> None:
conn.commit()
except Exception:
pass
for col, dtype in [
("engine_id", "TEXT"),
("capital_bucket_id", "TEXT"),
]:
try:
conn.execute(f"ALTER TABLE trades ADD COLUMN {col} {dtype}")
conn.commit()
except Exception:
pass
finally:
conn.close()

@ -20,6 +20,8 @@ class SessionRow:
created_at: str
status: str
parking_preset: str | None = None
idle_alpha_preset: str | None = None
form4_sleeve_preset: str | None = None
@dataclass
@ -87,14 +89,25 @@ class StateManager:
config_path: str,
initial_equity: float,
parking_preset: str | None = None,
idle_alpha_preset: str | None = None,
form4_sleeve_preset: str | None = None,
) -> str:
session_id = str(uuid.uuid4())[:8]
created_at = dt.datetime.now(tz=dt.timezone.utc).isoformat()
with self._connect() as conn:
conn.execute(
"INSERT INTO sessions (session_id, session_name, config_path, initial_equity, created_at, status, parking_preset) "
"VALUES (?, ?, ?, ?, ?, 'active', ?)",
(session_id, session_name, config_path, initial_equity, created_at, parking_preset),
"INSERT INTO sessions (session_id, session_name, config_path, initial_equity, created_at, status, parking_preset, idle_alpha_preset, form4_sleeve_preset) "
"VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)",
(
session_id,
session_name,
config_path,
initial_equity,
created_at,
parking_preset,
idle_alpha_preset,
form4_sleeve_preset,
),
)
conn.execute(
"INSERT INTO session_state (session_id) VALUES (?)",
@ -298,6 +311,8 @@ class StateManager:
self,
session_id: str,
symbol: str,
engine_id: str | None,
capital_bucket_id: str | None,
entry_date: str | None,
exit_date: str,
entry_price: float | None,
@ -311,11 +326,11 @@ class StateManager:
trade_id = str(uuid.uuid4())
with self._connect() as conn:
conn.execute(
"INSERT INTO trades (trade_id, session_id, symbol, entry_date, exit_date, "
"INSERT INTO trades (trade_id, session_id, symbol, engine_id, capital_bucket_id, entry_date, exit_date, "
"entry_price, exit_price, exit_reason, shares, net_pnl, r_multiple, holding_days) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
trade_id, session_id, symbol, entry_date, exit_date,
trade_id, session_id, symbol, engine_id, capital_bucket_id, entry_date, exit_date,
entry_price, exit_price, exit_reason, shares,
net_pnl, r_multiple, holding_days,
),
@ -445,6 +460,14 @@ class StateManager:
sgov_entry_value or entry_value),
)
def list_parking_entries(self, session_id: str) -> list[dict]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM parking_state WHERE session_id = ? ORDER BY entry_date",
(session_id,),
).fetchall()
return [dict(r) for r in rows]
def close_parking_state(self, session_id: str) -> None:
with self._connect() as conn:
conn.execute(

@ -0,0 +1,299 @@
"""Build leakage-safe daily Form 4 cluster parquet from SEC flat files.
The output is a daily same-day cluster cache keyed by filing date. Runtime code
uses this cache conservatively on the next trading day only.
"""
from __future__ import annotations
import argparse
import csv
import datetime as dt
import io
import math
import urllib.error
import urllib.request
import zipfile
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import pyarrow as pa
import pyarrow.parquet as pq
from apps.backtester.run import _build_merged_snapshot_store, load_manifest, resolve_config
from libs.common.logging import configure_logging
_DEFAULT_USER_AGENT = "fithia2-form4-cache/1.0 (local research; contact: dev@example.com)"
@dataclass(frozen=True)
class RawForm4Transaction:
symbol: str
filing_date: dt.date
transaction_date: dt.date
owner_cik: str
owner_relationship: str
owner_title: str
shares: float
price: float
total_value: float
shares_owned_following: float
purchase_pct_of_holding: float
def _parse_date(value: str, *, is_end: bool = False) -> dt.date:
parts = value.split("-")
if len(parts) == 1 and len(value) == 4 and value.isdigit():
year = int(value)
return dt.date(year, 12, 31) if is_end else dt.date(year, 1, 1)
if len(parts) == 2 and all(part.isdigit() for part in parts):
year = int(parts[0])
month = int(parts[1])
if is_end:
next_month = dt.date(year + (month // 12), (month % 12) + 1, 1)
return next_month - dt.timedelta(days=1)
return dt.date(year, month, 1)
return dt.date.fromisoformat(value)
def _quarter_range(start_date: dt.date, end_date: dt.date) -> list[tuple[int, int]]:
year = start_date.year
quarter = (start_date.month - 1) // 3 + 1
end_key = (end_date.year, (end_date.month - 1) // 3 + 1)
quarters: list[tuple[int, int]] = []
while (year, quarter) <= end_key:
quarters.append((year, quarter))
quarter += 1
if quarter == 5:
year += 1
quarter = 1
return quarters
def _quarter_zip_path(cache_dir: Path, year: int, quarter: int) -> Path:
return cache_dir / f"{year}q{quarter}_form345.zip"
def _quarter_zip_url(year: int, quarter: int) -> str:
return (
"https://www.sec.gov/files/structureddata/data/"
f"insider-transactions-data-sets/{year}q{quarter}_form345.zip"
)
def _ensure_quarter_zip(cache_dir: Path, year: int, quarter: int, *, user_agent: str) -> Path | None:
cache_dir.mkdir(parents=True, exist_ok=True)
path = _quarter_zip_path(cache_dir, year, quarter)
if path.exists() and path.stat().st_size > 0:
return path
request = urllib.request.Request(_quarter_zip_url(year, quarter), headers={"User-Agent": user_agent})
try:
with urllib.request.urlopen(request, timeout=90) as response:
data = response.read()
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
raise
path.write_bytes(data)
return path
def _parse_sec_date(value: str | None) -> dt.date | None:
if not value:
return None
try:
return dt.datetime.strptime(value, "%d-%b-%Y").date()
except ValueError:
return None
def _coerce_float(value: Any) -> float | None:
try:
result = float(value)
except (TypeError, ValueError):
return None
if math.isnan(result) or math.isinf(result):
return None
return result
def _is_officer_or_director(relationship: str, title: str) -> bool:
combined = f"{relationship} {title}".strip().lower()
return any(token in combined for token in ("director", "officer", "chief", "ceo", "cfo", "coo", "president", "chair"))
def _load_form4_transactions(
*,
cache_dir: Path,
start_date: dt.date,
end_date: dt.date,
allowed_symbols: set[str],
user_agent: str,
) -> tuple[list[RawForm4Transaction], list[str]]:
transactions: list[RawForm4Transaction] = []
skipped_quarters: list[str] = []
for year, quarter in _quarter_range(start_date, end_date):
path = _ensure_quarter_zip(cache_dir, year, quarter, user_agent=user_agent)
if path is None:
skipped_quarters.append(f"{year}Q{quarter}")
continue
with zipfile.ZipFile(path) as zf:
submissions: dict[str, dict[str, Any]] = {}
with zf.open("SUBMISSION.tsv") as handle:
reader = csv.DictReader(io.TextIOWrapper(handle, encoding="utf-8", newline=""), delimiter="\t")
for row in reader:
symbol = str(row.get("ISSUERTRADINGSYMBOL") or "").strip().upper()
if not symbol or symbol not in allowed_symbols:
continue
if str(row.get("DOCUMENT_TYPE") or "").strip().upper() != "4":
continue
filing_date = _parse_sec_date(row.get("FILING_DATE"))
if filing_date is None or filing_date < start_date or filing_date > end_date:
continue
submissions[str(row["ACCESSION_NUMBER"])] = {
"symbol": symbol,
"filing_date": filing_date,
}
if not submissions:
continue
relationships: dict[str, list[tuple[str, str, str]]] = defaultdict(list)
with zf.open("REPORTINGOWNER.tsv") as handle:
reader = csv.DictReader(io.TextIOWrapper(handle, encoding="utf-8", newline=""), delimiter="\t")
for row in reader:
accession = str(row["ACCESSION_NUMBER"])
if accession not in submissions:
continue
owner_cik = str(row.get("RPTOWNERCIK") or "").strip()
relationship = str(row.get("OFFICER_TITLE") or "").strip()
title = str(row.get("OTHER_TEXT") or "").strip()
relationships[accession].append((owner_cik, relationship, title))
with zf.open("NONDERIV_TRANS.tsv") as handle:
reader = csv.DictReader(io.TextIOWrapper(handle, encoding="utf-8", newline=""), delimiter="\t")
for row in reader:
accession = str(row["ACCESSION_NUMBER"])
submission = submissions.get(accession)
if submission is None:
continue
transaction_code = str(row.get("TRANS_CODE") or "").strip().upper()
if transaction_code != "P":
continue
shares = _coerce_float(row.get("TRANS_SHARES"))
price = _coerce_float(row.get("TRANS_PRICEPERSHARE"))
if shares is None or price is None or shares <= 0 or price <= 0:
continue
transaction_date = _parse_sec_date(row.get("TRANS_DATE")) or submission["filing_date"]
shares_owned_following = _coerce_float(row.get("SHRS_OWND_FOLWNG_TRANS")) or 0.0
purchase_pct = 0.0
if shares_owned_following > 0:
purchase_pct = min(1.0, shares / shares_owned_following)
rels = relationships.get(accession) or [("", "", "")]
for owner_cik, relationship, title in rels:
transactions.append(
RawForm4Transaction(
symbol=submission["symbol"],
filing_date=submission["filing_date"],
transaction_date=transaction_date,
owner_cik=owner_cik,
owner_relationship=relationship,
owner_title=title,
shares=shares,
price=price,
total_value=shares * price,
shares_owned_following=shares_owned_following,
purchase_pct_of_holding=purchase_pct,
)
)
return transactions, skipped_quarters
def _aggregate_daily_events(transactions: list[RawForm4Transaction]) -> list[dict[str, Any]]:
grouped: dict[tuple[dt.date, str], list[RawForm4Transaction]] = defaultdict(list)
for row in transactions:
grouped[(row.filing_date, row.symbol)].append(row)
events: list[dict[str, Any]] = []
for (filing_date, symbol), rows in grouped.items():
owner_keys = {row.owner_cik or f"{symbol}:{idx}" for idx, row in enumerate(rows)}
purchase_values = [row.purchase_pct_of_holding for row in rows if row.purchase_pct_of_holding > 0]
lag_values = [(row.filing_date - row.transaction_date).days for row in rows]
events.append(
{
"symbol": symbol,
"filing_date": filing_date.isoformat(),
"as_of_date": filing_date.isoformat(),
"total_value": round(sum(row.total_value for row in rows), 2),
"owner_count": len(owner_keys),
"transaction_count": len(rows),
"event_day_count": 1,
"max_purchase_pct": max(purchase_values) if purchase_values else 0.0,
"median_purchase_pct": sorted(purchase_values)[len(purchase_values) // 2] if purchase_values else 0.0,
"weighted_purchase_pct": max(purchase_values) if purchase_values else 0.0,
"max_lag_days": max(lag_values) if lag_values else None,
"min_lag_days": min(lag_values) if lag_values else None,
"has_officer_or_director": any(
_is_officer_or_director(row.owner_relationship, row.owner_title)
for row in rows
),
}
)
events.sort(key=lambda row: (row["filing_date"], row["symbol"]))
return events
def _allowed_symbols_from_manifest(manifest_path: str, start_date: dt.date, end_date: dt.date) -> set[str]:
manifest = load_manifest(manifest_path)
config = resolve_config(manifest)
store = _build_merged_snapshot_store(
manifest,
config,
snapshot_dir_override=None,
).slice_by_date_range(start_date, end_date)
return {str(symbol).upper() for symbol in store._bars.keys()}
def main() -> None:
parser = argparse.ArgumentParser(description="Build daily Form 4 same-day cluster parquet")
parser.add_argument("--config", required=True, help="Experiment manifest JSON path")
parser.add_argument("--start", required=True, help="Start date (YYYY, YYYY-MM, YYYY-MM-DD)")
parser.add_argument("--end", required=True, help="End date (YYYY, YYYY-MM, YYYY-MM-DD)")
parser.add_argument("--cache-dir", default="data/cache/form4", help="SEC zip cache dir")
parser.add_argument("--output", default="data/reference/form4_daily_events_pit.parquet", help="Output parquet path")
parser.add_argument("--user-agent", default=_DEFAULT_USER_AGENT, help="SEC User-Agent header")
args = parser.parse_args()
configure_logging("INFO")
start_date = _parse_date(args.start)
end_date = _parse_date(args.end, is_end=True)
allowed_symbols = _allowed_symbols_from_manifest(args.config, start_date, end_date)
transactions, skipped_quarters = _load_form4_transactions(
cache_dir=Path(args.cache_dir),
start_date=start_date,
end_date=end_date,
allowed_symbols=allowed_symbols,
user_agent=args.user_agent,
)
events = _aggregate_daily_events(transactions)
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
table = pa.Table.from_pylist(events)
pq.write_table(table, str(output_path))
print(
{
"output": str(output_path),
"symbols": len({row["symbol"] for row in events}),
"events": len(events),
"transactions": len(transactions),
"skipped_quarters": skipped_quarters,
}
)
if __name__ == "__main__":
main()

@ -0,0 +1,95 @@
"""Direct backtest subprocess runner.
Invoked by the web server as a subprocess:
python -m apps.web.direct_runner TASK_ID CONFIG_PATH CAPITAL START_DATE END_DATE RESULT_FILE [--parking PRESET] [--idle-alpha PRESET] [--form4-sleeve PRESET]
Runs run_backtest_session_sync() and saves the result JSON to RESULT_FILE.
Exits 0 on success, non-zero on failure.
"""
from __future__ import annotations
import datetime as dt
import json
import sys
from pathlib import Path
def _json_default(obj):
if isinstance(obj, (dt.date, dt.datetime)):
return obj.isoformat()
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
def main():
if len(sys.argv) < 7:
print(
"Usage: direct_runner TASK_ID CONFIG_PATH CAPITAL START_DATE END_DATE RESULT_FILE [--parking PRESET] [--idle-alpha PRESET] [--form4-sleeve PRESET]",
file=sys.stderr,
)
sys.exit(2)
task_id = sys.argv[1]
config_path = sys.argv[2]
capital = float(sys.argv[3])
start_date = dt.date.fromisoformat(sys.argv[4])
end_date = dt.date.fromisoformat(sys.argv[5])
result_file = Path(sys.argv[6])
# Optional --parking PRESET, --idle-alpha PRESET, --form4-sleeve PRESET, and --snapshot-id ID
parking_preset = None
idle_alpha_preset = None
form4_sleeve_preset = None
snapshot_id_override = None
remaining = sys.argv[7:]
i = 0
while i < len(remaining):
if remaining[i] == "--parking" and i + 1 < len(remaining):
parking_preset = remaining[i + 1]
i += 2
elif remaining[i] == "--idle-alpha" and i + 1 < len(remaining):
idle_alpha_preset = remaining[i + 1]
i += 2
elif remaining[i] == "--form4-sleeve" and i + 1 < len(remaining):
form4_sleeve_preset = remaining[i + 1]
i += 2
elif remaining[i] == "--snapshot-id" and i + 1 < len(remaining):
snapshot_id_override = remaining[i + 1]
i += 2
else:
i += 1
print(f"[direct] {task_id} · {Path(config_path).stem} · {start_date}{end_date}" +
(f" · parking={parking_preset}" if parking_preset else "") +
(f" · idle_alpha={idle_alpha_preset}" if idle_alpha_preset else "") +
(f" · form4={form4_sleeve_preset}" if form4_sleeve_preset else "") +
(f" · snapshot={snapshot_id_override}" if snapshot_id_override else ""))
sys.stdout.flush()
from apps.paper_trader.backtest_sim import run_backtest_session_sync
result = run_backtest_session_sync(
session_name=Path(config_path).stem,
config_path=config_path,
initial_equity=capital,
start_date=start_date,
end_date=end_date,
parking_preset=parking_preset,
idle_alpha_preset=idle_alpha_preset,
form4_sleeve_preset=form4_sleeve_preset,
snapshot_id_override=snapshot_id_override,
)
result_file.parent.mkdir(parents=True, exist_ok=True)
result_file.write_text(json.dumps(result, default=_json_default, indent=2))
s = result.get("summary", {})
print(
f"[direct] done · return={s.get('return_pct', 0):+.2f}% "
f"trades={s.get('trade_count', 0)} "
f"sharpe={s.get('sharpe', 0):.2f}"
)
sys.stdout.flush()
if __name__ == "__main__":
main()

@ -20,7 +20,11 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from apps.web.dependencies import get_configs_dir, get_project_root, get_runs_dir
from libs.backtest.domain import PARKING_PRESETS
from libs.backtest.domain import (
FORM4_CAPTURE_SLEEVE_PRESETS,
IDLE_ALPHA_SLEEVE_PRESETS,
PARKING_PRESETS,
)
router = APIRouter(prefix="/backtest", tags=["backtest"])
@ -341,6 +345,8 @@ class BacktestRequest(BaseModel):
no_trades: bool = False
mode: str = "cli" # "cli" (subprocess) or "direct" (in-process)
parking: str | None = None # cash parking preset name
idle_alpha: str | None = None # idle alpha sleeve preset name
form4_sleeve: str | None = None # Form 4 residual-cash sleeve preset name
snapshot_id: str | None = None # override dataset_snapshot_id (e.g. for OOT periods)
@ -351,6 +357,9 @@ class BatchBacktestRequest(BaseModel):
end: str | None = None
year: str | None = None
no_trades: bool = False
parking: str | None = None
idle_alpha: str | None = None
form4_sleeve: str | None = None
# ---------------------------------------------------------------------------
@ -366,6 +375,8 @@ def _make_task(
no_trades: bool = False,
mode: str = "cli",
parking: str | None = None,
idle_alpha: str | None = None,
form4_sleeve: str | None = None,
snapshot_id: str | None = None,
) -> dict[str, Any]:
return {
@ -386,6 +397,8 @@ def _make_task(
"mode": mode,
"has_direct_result": False,
"parking": parking,
"idle_alpha": idle_alpha,
"form4_sleeve": form4_sleeve,
"snapshot_id": snapshot_id,
}
@ -493,7 +506,19 @@ def _watch_process(task_id: str, proc: subprocess.Popen[bytes], output_root: Pat
_persist_task(task)
def _build_cmd(config_paths: list[Path], capital: float, start: str | None, end: str | None, year: str | None, no_trades: bool, runs_dir: Path, parking: str | None = None, snapshot_id: str | None = None) -> list[str]:
def _build_cmd(
config_paths: list[Path],
capital: float,
start: str | None,
end: str | None,
year: str | None,
no_trades: bool,
runs_dir: Path,
parking: str | None = None,
idle_alpha: str | None = None,
form4_sleeve: str | None = None,
snapshot_id: str | None = None,
) -> list[str]:
"""Build the paper backtest CLI command."""
cmd = [
sys.executable, "-m", "apps.paper_trader.cli", "backtest",
@ -512,6 +537,10 @@ def _build_cmd(config_paths: list[Path], capital: float, start: str | None, end:
cmd.append("--no-trades")
if parking:
cmd += ["--parking", parking]
if idle_alpha:
cmd += ["--idle-alpha", idle_alpha]
if form4_sleeve:
cmd += ["--form4-sleeve", form4_sleeve]
if snapshot_id:
cmd += ["--snapshot-id", snapshot_id]
return cmd
@ -526,8 +555,25 @@ def _launch_backtest(req: BacktestRequest) -> dict[str, Any]:
config_path = configs_dir / f"{req.experiment_name}.json"
if not config_path.exists():
raise HTTPException(status_code=404, detail=f"Experiment config not found: {req.experiment_name}")
if req.parking and req.parking not in PARKING_PRESETS:
raise HTTPException(status_code=400, detail=f"Unknown parking preset: {req.parking}")
if req.idle_alpha and req.idle_alpha not in IDLE_ALPHA_SLEEVE_PRESETS:
raise HTTPException(status_code=400, detail=f"Unknown idle alpha preset: {req.idle_alpha}")
if req.form4_sleeve and req.form4_sleeve not in FORM4_CAPTURE_SLEEVE_PRESETS:
raise HTTPException(status_code=400, detail=f"Unknown Form 4 sleeve preset: {req.form4_sleeve}")
task = _make_task(req.experiment_name, req.capital, req.start, req.end, req.year, req.no_trades, parking=req.parking, snapshot_id=req.snapshot_id)
task = _make_task(
req.experiment_name,
req.capital,
req.start,
req.end,
req.year,
req.no_trades,
parking=req.parking,
idle_alpha=req.idle_alpha,
form4_sleeve=req.form4_sleeve,
snapshot_id=req.snapshot_id,
)
task_id = task["task_id"]
# Log directory
@ -544,10 +590,25 @@ def _launch_backtest(req: BacktestRequest) -> dict[str, Any]:
"end": req.end,
"year": req.year,
"no_trades": req.no_trades,
"parking": req.parking,
"idle_alpha": req.idle_alpha,
"form4_sleeve": req.form4_sleeve,
"created_at": task["created_at"],
}, indent=2))
cmd = _build_cmd([config_path], req.capital, req.start, req.end, req.year, req.no_trades, runs_dir, req.parking, req.snapshot_id)
cmd = _build_cmd(
[config_path],
req.capital,
req.start,
req.end,
req.year,
req.no_trades,
runs_dir,
req.parking,
req.idle_alpha,
req.form4_sleeve,
req.snapshot_id,
)
with open(log_file, "wb") as log_fp:
proc = subprocess.Popen(
@ -571,7 +632,17 @@ def _launch_backtest(req: BacktestRequest) -> dict[str, Any]:
return task
def _launch_backtest_multi(names: list[str], capital: float, start: str | None, end: str | None, year: str | None, no_trades: bool) -> dict[str, Any]:
def _launch_backtest_multi(
names: list[str],
capital: float,
start: str | None,
end: str | None,
year: str | None,
no_trades: bool,
parking: str | None = None,
idle_alpha: str | None = None,
form4_sleeve: str | None = None,
) -> dict[str, Any]:
"""Launch ONE subprocess with multiple --config flags for batch experiments."""
project_root = get_project_root()
configs_dir = get_configs_dir()
@ -585,7 +656,17 @@ def _launch_backtest_multi(names: list[str], capital: float, start: str | None,
config_paths.append(cp)
display_name = ", ".join(names)
task = _make_task(display_name, capital, start, end, year, no_trades)
task = _make_task(
display_name,
capital,
start,
end,
year,
no_trades,
parking=parking,
idle_alpha=idle_alpha,
form4_sleeve=form4_sleeve,
)
task_id = task["task_id"]
log_dir = runs_dir / ".backtest_tasks"
@ -603,10 +684,24 @@ def _launch_backtest_multi(names: list[str], capital: float, start: str | None,
"end": end,
"year": year,
"no_trades": no_trades,
"parking": parking,
"idle_alpha": idle_alpha,
"form4_sleeve": form4_sleeve,
"created_at": created_at,
}, indent=2))
cmd = _build_cmd(config_paths, capital, start, end, year, no_trades, runs_dir)
cmd = _build_cmd(
config_paths,
capital,
start,
end,
year,
no_trades,
runs_dir,
parking,
idle_alpha,
form4_sleeve,
)
with open(log_file, "wb") as log_fp:
proc = subprocess.Popen(
@ -691,7 +786,11 @@ def _launch_direct_backtest(req: BacktestRequest) -> dict[str, Any]:
task = _make_task(
req.experiment_name, req.capital, req.start, req.end, req.year, req.no_trades,
mode="direct", parking=req.parking, snapshot_id=req.snapshot_id,
mode="direct",
parking=req.parking,
idle_alpha=req.idle_alpha,
form4_sleeve=req.form4_sleeve,
snapshot_id=req.snapshot_id,
)
task_id = task["task_id"]
@ -710,6 +809,10 @@ def _launch_direct_backtest(req: BacktestRequest) -> dict[str, Any]:
]
if req.parking:
cmd += ["--parking", req.parking]
if req.idle_alpha:
cmd += ["--idle-alpha", req.idle_alpha]
if req.form4_sleeve:
cmd += ["--form4-sleeve", req.form4_sleeve]
if req.snapshot_id:
cmd += ["--snapshot-id", req.snapshot_id]
@ -763,6 +866,9 @@ def submit_batch(req: BatchBacktestRequest) -> dict[str, Any]:
end=req.end,
year=req.year,
no_trades=req.no_trades,
parking=req.parking,
idle_alpha=req.idle_alpha,
form4_sleeve=req.form4_sleeve,
)
task = _launch_backtest(single)
return {"tasks": [task]}
@ -774,6 +880,9 @@ def submit_batch(req: BatchBacktestRequest) -> dict[str, Any]:
end=req.end,
year=req.year,
no_trades=req.no_trades,
parking=req.parking,
idle_alpha=req.idle_alpha,
form4_sleeve=req.form4_sleeve,
)
return {"tasks": [task]}
@ -1024,3 +1133,17 @@ def get_parking_presets() -> dict[str, Any]:
groups.setdefault(group, []).append(name)
return {"presets": list(PARKING_PRESETS.keys()), "groups": groups}
@router.get("/idle-alpha-presets")
def get_idle_alpha_presets() -> dict[str, Any]:
"""Return named idle-alpha sleeve presets."""
groups = {"Residual Event Sleeve": list(IDLE_ALPHA_SLEEVE_PRESETS.keys())}
return {"presets": list(IDLE_ALPHA_SLEEVE_PRESETS.keys()), "groups": groups}
@router.get("/form4-capture-presets")
def get_form4_capture_presets() -> dict[str, Any]:
"""Return named Form 4 residual-cash sleeve presets."""
groups = {"Residual Insider Sleeve": list(FORM4_CAPTURE_SLEEVE_PRESETS.keys())}
return {"presets": list(FORM4_CAPTURE_SLEEVE_PRESETS.keys()), "groups": groups}

@ -55,6 +55,8 @@ def _session_summary(session, state) -> dict[str, Any]:
"created_at": session.created_at,
"status": session.status,
"parking_preset": session.parking_preset,
"idle_alpha_preset": session.idle_alpha_preset,
"form4_sleeve_preset": session.form4_sleeve_preset,
"current_equity": current,
"peak_equity": peak,
"total_pnl": total_pnl,
@ -110,6 +112,8 @@ class CreateSessionRequest(BaseModel):
config: str # experiment name, numeric ID, or config path
capital: float = 10000.0
parking: str | None = None # optional parking preset name
idle_alpha: str | None = None # optional idle alpha sleeve preset name
form4_sleeve: str | None = None # optional Form 4 sleeve preset name
@router.post("/sessions")
@ -132,6 +136,20 @@ def create_session(req: CreateSessionRequest) -> dict[str, Any]:
raise HTTPException(status_code=400, detail=f"Unknown parking preset: {req.parking}")
except ImportError:
pass
if req.idle_alpha:
try:
from libs.backtest.domain import IDLE_ALPHA_SLEEVE_PRESETS
if req.idle_alpha not in IDLE_ALPHA_SLEEVE_PRESETS:
raise HTTPException(status_code=400, detail=f"Unknown idle alpha preset: {req.idle_alpha}")
except ImportError:
pass
if req.form4_sleeve:
try:
from libs.backtest.domain import FORM4_CAPTURE_SLEEVE_PRESETS
if req.form4_sleeve not in FORM4_CAPTURE_SLEEVE_PRESETS:
raise HTTPException(status_code=400, detail=f"Unknown Form 4 sleeve preset: {req.form4_sleeve}")
except ImportError:
pass
state = _get_state_manager()
if state.get_session(req.name) is not None:
@ -142,8 +160,18 @@ def create_session(req: CreateSessionRequest) -> dict[str, Any]:
config_path=config_path,
initial_equity=req.capital,
parking_preset=req.parking or None,
idle_alpha_preset=req.idle_alpha or None,
form4_sleeve_preset=req.form4_sleeve or None,
)
return {"session_id": session_id, "name": req.name, "capital": req.capital, "config": config_path, "parking": req.parking}
return {
"session_id": session_id,
"name": req.name,
"capital": req.capital,
"config": config_path,
"parking": req.parking,
"idle_alpha": req.idle_alpha,
"form4_sleeve": req.form4_sleeve,
}
@router.get("/sessions/{session_id}")
@ -295,7 +323,36 @@ def get_trades(
session = state.get_session(session_id)
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
import datetime
trades = state.list_trades(session.session_id, limit=last)
# Include parking entries (active + closed) as parking-sleeve trades
parking_entries = state.list_parking_entries(session.session_id)
today = datetime.date.today()
for p in parking_entries:
entry_date = p.get("entry_date", "")
try:
days = (today - datetime.date.fromisoformat(entry_date)).days
except Exception:
days = 0
is_active = p.get("status") == "active"
trades.append({
"trade_id": f"parking_{session_id}_{entry_date}_{p['symbol']}",
"session_id": session_id,
"symbol": p["symbol"],
"entry_date": entry_date,
"exit_date": None if is_active else entry_date,
"entry_price": p.get("avg_price"),
"exit_price": None,
"exit_reason": "active" if is_active else "closed",
"shares": p.get("qty"),
"net_pnl": None,
"r_multiple": None,
"holding_days": days,
"engine_id": "cash_parking",
"capital_bucket_id": "parking",
})
return {"trades": trades, "total": len(trades)}

File diff suppressed because one or more lines are too long

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fithia2</title>
<script type="module" crossorigin src="/assets/index-DS-hUHgy.js"></script>
<script type="module" crossorigin src="/assets/index-BxBohZ7I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-4dA-62MI.css">
</head>
<body>

@ -126,6 +126,7 @@ export interface LeaderboardEntry {
strategy_family: string;
is_retired: boolean;
sqs_score: number | null;
reset_common_window_score: number | null;
rqs_score: number | null;
wfqs_score: number | null;
deployment_score: number | null;
@ -196,6 +197,8 @@ export interface BacktestRequest {
no_trades?: boolean;
mode?: string; // "cli" (default) or "direct" (in-process)
parking?: string | null; // cash parking preset name
idle_alpha?: string | null; // idle alpha sleeve preset name
form4_sleeve?: string | null; // Form 4 residual-cash sleeve preset name
snapshot_id?: string | null; // override dataset snapshot
}
@ -223,6 +226,8 @@ export interface BacktestTask {
mode?: 'cli' | 'direct';
has_direct_result?: boolean;
parking?: string | null;
idle_alpha?: string | null;
form4_sleeve?: string | null;
snapshot_id?: string | null;
result_summary?: {
return_pct: number | null;
@ -277,6 +282,12 @@ export const backtestApi = {
parkingPresets: () =>
request<{ presets: string[]; groups: Record<string, string[]> }>('/backtest/parking-presets'),
idleAlphaPresets: () =>
request<{ presets: string[]; groups: Record<string, string[]> }>('/backtest/idle-alpha-presets'),
form4CapturePresets: () =>
request<{ presets: string[]; groups: Record<string, string[]> }>('/backtest/form4-capture-presets'),
snapshots: () =>
request<{ snapshots: { id: string; start: string | null; end: string | null; rows: number | null }[] }>('/backtest/snapshots'),
};
@ -297,6 +308,8 @@ export interface PaperSession {
created_at: string;
status: 'active' | 'paused' | 'closed';
parking_preset: string | null;
idle_alpha_preset: string | null;
form4_sleeve_preset: string | null;
current_equity: number;
peak_equity: number;
total_pnl: number;
@ -331,6 +344,8 @@ export interface PaperTrade {
trade_id: string;
session_id: string;
symbol: string;
engine_id: string | null;
trade_sleeve?: string | null;
entry_date: string | null;
exit_date: string;
entry_price: number | null;
@ -380,15 +395,38 @@ export const paperApi = {
// Sessions
sessions: () => request<{ sessions: PaperSession[] }>('/paper/sessions'),
createSession: (name: string, config: string, capital: number, parking?: string | null) =>
request<{ session_id: string; name: string; capital: number; config: string; parking?: string }>('/paper/sessions', {
method: 'POST',
body: JSON.stringify({ name, config, capital, parking: parking || null }),
}),
createSession: (
name: string,
config: string,
capital: number,
parking?: string | null,
idleAlpha?: string | null,
form4Sleeve?: string | null,
) =>
request<{ session_id: string; name: string; capital: number; config: string; parking?: string; idle_alpha?: string; form4_sleeve?: string }>(
'/paper/sessions',
{
method: 'POST',
body: JSON.stringify({
name,
config,
capital,
parking: parking || null,
idle_alpha: idleAlpha || null,
form4_sleeve: form4Sleeve || null,
}),
},
),
parkingPresets: () =>
request<{ presets: string[]; groups: Record<string, string[]> }>('/backtest/parking-presets'),
idleAlphaPresets: () =>
request<{ presets: string[]; groups: Record<string, string[]> }>('/backtest/idle-alpha-presets'),
form4CapturePresets: () =>
request<{ presets: string[]; groups: Record<string, string[]> }>('/backtest/form4-capture-presets'),
getSession: (sessionId: string) =>
request<PaperSession>(`/paper/sessions/${encodeURIComponent(sessionId)}`),

@ -12,6 +12,7 @@ import {
} from 'recharts';
import { backtestApi, runsApi, experimentsApi, type BacktestTask } from '../api/client';
import { Loading, ErrorState } from '../components/common/Loading';
import { classifyTradeSleeve, tradeSleeveLabel, type TradeSleeve } from '../lib/tradeSleeves';
import { fmtDate, ansiToHtml } from '../lib/utils';
// ── Shared helpers ───────────────────────────────────────────────────────────
@ -54,6 +55,8 @@ interface DupState {
end: string | null;
no_trades: boolean;
parking: string | null;
idle_alpha: string | null;
form4_sleeve: string | null;
snapshot_id: string | null;
}
@ -67,6 +70,8 @@ function makeDupState(task: BacktestTask): DupState {
end: !isYear ? task.end_date ?? null : null,
no_trades: task.no_trades ?? false,
parking: task.parking ?? null,
idle_alpha: task.idle_alpha ?? null,
form4_sleeve: task.form4_sleeve ?? null,
snapshot_id: task.snapshot_id ?? null,
};
}
@ -102,6 +107,31 @@ function TerminalLog({ text, large = false }: { text: string; large?: boolean })
);
}
function SleeveBadge({ sleeve }: { sleeve: TradeSleeve }) {
const tone = sleeve === 'parking'
? { color: 'var(--gold)', bg: 'var(--gold-dim)' }
: sleeve === 'idle_alpha'
? { color: 'var(--cyan)', bg: 'var(--cyan-dim)' }
: { color: 'var(--text2)', bg: 'var(--bg2)' };
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
padding: '2px 8px',
borderRadius: 999,
fontFamily: 'var(--font-mono)',
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.04em',
color: tone.color,
background: tone.bg,
whiteSpace: 'nowrap',
}}>
{tradeSleeveLabel(sleeve)}
</span>
);
}
// ── Backtest Task List Page ───────────────────────────────────────────────────
export function BacktestPage() {
@ -122,13 +152,15 @@ export function BacktestPage() {
// Date mode: 'year' (single YYYY) or 'range' (start + optional end)
const [dateMode, setDateMode] = useState<'year' | 'range'>('range');
const [year, setYear] = useState('');
const [startDate, setStartDate] = useState('');
const [year, setYear] = useState('2022');
const [startDate, setStartDate] = useState('2022');
const [endDate, setEndDate] = useState('');
const [capital, setCapital] = useState('10000');
const [noTrades, setNoTrades] = useState(false);
const [directMode, setDirectMode] = useState(true);
const [parking, setParking] = useState('');
const [idleAlpha, setIdleAlpha] = useState('');
const [form4Sleeve, setForm4Sleeve] = useState('');
const [snapshotId, setSnapshotId] = useState('');
// Directly apply dup state to form — used both inline and on mount
@ -154,6 +186,8 @@ export function BacktestPage() {
}
setNoTrades(s.no_trades ?? false);
setParking(s.parking ?? '');
setIdleAlpha(s.idle_alpha ?? '');
setForm4Sleeve(s.form4_sleeve ?? '');
setSnapshotId(s.snapshot_id ?? '');
};
@ -179,6 +213,16 @@ export function BacktestPage() {
queryFn: () => backtestApi.parkingPresets(),
staleTime: 60_000,
});
const { data: idleAlphaPresetsData } = useQuery({
queryKey: ['idle-alpha-presets'],
queryFn: () => backtestApi.idleAlphaPresets(),
staleTime: 60_000,
});
const { data: form4PresetsData } = useQuery({
queryKey: ['form4-capture-presets'],
queryFn: () => backtestApi.form4CapturePresets(),
staleTime: 60_000,
});
// Available snapshots
const { data: snapshotsData } = useQuery({
@ -231,6 +275,8 @@ export function BacktestPage() {
: (endDate || null),
no_trades: noTrades,
parking: parking || null,
idle_alpha: idleAlpha || null,
form4_sleeve: form4Sleeve || null,
snapshot_id: snapshotId || null,
};
if (selectedExps.length === 1) {
@ -557,6 +603,46 @@ export function BacktestPage() {
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)', whiteSpace: 'nowrap' }}>Idle Alpha</span>
<select
value={idleAlpha}
onChange={e => setIdleAlpha(e.target.value)}
style={{ ...inputStyle, width: 'auto', minWidth: 180, fontSize: 12, padding: '5px 8px', color: idleAlpha ? 'var(--green)' : 'var(--text3)' }}
>
<option value=""> none </option>
{idleAlphaPresetsData
? Object.entries(idleAlphaPresetsData.groups).map(([group, names]) => (
<optgroup key={group} label={group}>
{names.map(name => (
<option key={name} value={name}>{name}</option>
))}
</optgroup>
))
: null}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)', whiteSpace: 'nowrap' }}>Form 4 Sleeve</span>
<select
value={form4Sleeve}
onChange={e => setForm4Sleeve(e.target.value)}
style={{ ...inputStyle, width: 'auto', minWidth: 180, fontSize: 12, padding: '5px 8px', color: form4Sleeve ? 'var(--cyan)' : 'var(--text3)' }}
>
<option value=""> none </option>
{form4PresetsData
? Object.entries(form4PresetsData.groups).map(([group, names]) => (
<optgroup key={group} label={group}>
{names.map(name => (
<option key={name} value={name}>{name}</option>
))}
</optgroup>
))
: null}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={noTrades} onChange={e => setNoTrades(e.target.checked)} style={{ width: 13, height: 13 }} />
@ -800,6 +886,28 @@ export function BacktestPage() {
🅿 {task.parking}
</span>
)}
{task.idle_alpha && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
color: 'var(--green)',
background: 'color-mix(in srgb, var(--green) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--green) 25%, transparent)',
borderRadius: 3, padding: '1px 5px',
}}>
IA {task.idle_alpha}
</span>
)}
{task.form4_sleeve && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
color: 'var(--cyan)',
background: 'color-mix(in srgb, var(--cyan) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--cyan) 25%, transparent)',
borderRadius: 3, padding: '1px 5px',
}}>
F4 {task.form4_sleeve}
</span>
)}
</div>
</td>
<td style={{ padding: '11px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--cyan)', whiteSpace: 'nowrap' }}>
@ -973,6 +1081,28 @@ export function BacktestTaskDetailPage() {
🅿 {task.parking}
</span>
)}
{task.idle_alpha && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
color: 'var(--green)',
background: 'color-mix(in srgb, var(--green) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--green) 25%, transparent)',
borderRadius: 3, padding: '1px 6px',
}}>
IA {task.idle_alpha}
</span>
)}
{task.form4_sleeve && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
color: 'var(--cyan)',
background: 'color-mix(in srgb, var(--cyan) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--cyan) 25%, transparent)',
borderRadius: 3, padding: '1px 6px',
}}>
F4 {task.form4_sleeve}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
@ -1106,7 +1236,13 @@ function DirectModeTaskView({
const [tradePage, setTradePage] = useState(0);
const TRADE_PAGE_SIZE = 50;
const [tradeSort, setTradeSort] = useState<{ col: string; dir: 'asc' | 'desc' }>({ col: '_no', dir: 'asc' });
const [tradeFilter, setTradeFilter] = useState({ symbol: '', engine: '', reason: '', outcome: 'all' as 'all' | 'win' | 'loss' });
const [tradeFilter, setTradeFilter] = useState({
symbol: '',
sleeve: '' as '' | TradeSleeve,
engine: '',
reason: '',
outcome: 'all' as 'all' | 'win' | 'loss',
});
const { data: result, isLoading } = useQuery({
queryKey: ['direct-result', task.task_id],
@ -1233,16 +1369,22 @@ function DirectModeTaskView({
const chartData = (result.equity_curve ?? []).map(row => ({ date: row.date, equity: row.equity }));
const allTrades = ((result.trades ?? []) as Record<string, unknown>[]).map((t, i) => ({ ...t, _no: i + 1 } as Record<string, unknown>));
const allTrades = ((result.trades ?? []) as Record<string, unknown>[]).map((t, i) => ({
...t,
_no: i + 1,
_sleeve: classifyTradeSleeve(t),
})) as (Record<string, unknown> & { _no: number; _sleeve: TradeSleeve })[];
const totalTrades = allTrades.length;
// unique filter options
const sleeveOptions: TradeSleeve[] = ['core', 'idle_alpha', 'parking'];
const engineOptions = Array.from(new Set(allTrades.map(t => String(t.engine_id ?? '')))).filter(Boolean).sort();
const reasonOptions = Array.from(new Set(allTrades.map(t => String(t.reason ?? t.exit_reason ?? '')))).filter(v => v !== '—' && v !== '').sort();
// apply filters
const filteredTrades = allTrades.filter(t => {
if (tradeFilter.symbol && !String(t.symbol ?? '').toLowerCase().includes(tradeFilter.symbol.toLowerCase())) return false;
if (tradeFilter.sleeve && t._sleeve !== tradeFilter.sleeve) return false;
if (tradeFilter.engine && String(t.engine_id ?? '') !== tradeFilter.engine) return false;
if (tradeFilter.reason && String(t.reason ?? t.exit_reason ?? '') !== tradeFilter.reason) return false;
if (tradeFilter.outcome === 'win' && Number(t.pnl ?? 0) <= 0) return false;
@ -1379,6 +1521,14 @@ function DirectModeTaskView({
onChange={e => { setTradeFilter(f => ({ ...f, symbol: e.target.value })); setTradePage(0); }}
style={{ fontFamily: 'var(--font-mono)', fontSize: 12, background: 'var(--bg2)', border: '1px solid var(--border-md)', borderRadius: 6, padding: '5px 10px', color: 'var(--text1)', outline: 'none', width: 120 }}
/>
<select
value={tradeFilter.sleeve}
onChange={e => { setTradeFilter(f => ({ ...f, sleeve: e.target.value as '' | TradeSleeve })); setTradePage(0); }}
style={{ fontFamily: 'var(--font-mono)', fontSize: 12, background: 'var(--bg2)', border: '1px solid var(--border-md)', borderRadius: 6, padding: '5px 10px', color: 'var(--text2)', outline: 'none', cursor: 'pointer' }}
>
<option value="">All Sleeves</option>
{sleeveOptions.map(s => <option key={s} value={s}>{tradeSleeveLabel(s)}</option>)}
</select>
<select
value={tradeFilter.engine}
onChange={e => { setTradeFilter(f => ({ ...f, engine: e.target.value })); setTradePage(0); }}
@ -1407,8 +1557,8 @@ function DirectModeTaskView({
</button>
))}
</div>
{(tradeFilter.symbol || tradeFilter.engine || tradeFilter.reason || tradeFilter.outcome !== 'all') && (
<button onClick={() => { setTradeFilter({ symbol: '', engine: '', reason: '', outcome: 'all' }); setTradePage(0); }} style={{ fontFamily: 'var(--font-mono)', fontSize: 12, background: 'none', border: '1px solid var(--border-md)', borderRadius: 6, padding: '5px 10px', color: 'var(--text3)', cursor: 'pointer' }}>
{(tradeFilter.symbol || tradeFilter.sleeve || tradeFilter.engine || tradeFilter.reason || tradeFilter.outcome !== 'all') && (
<button onClick={() => { setTradeFilter({ symbol: '', sleeve: '', engine: '', reason: '', outcome: 'all' }); setTradePage(0); }} style={{ fontFamily: 'var(--font-mono)', fontSize: 12, background: 'none', border: '1px solid var(--border-md)', borderRadius: 6, padding: '5px 10px', color: 'var(--text3)', cursor: 'pointer' }}>
Clear
</button>
)}
@ -1433,6 +1583,7 @@ function DirectModeTaskView({
{[
{ label: 'No.', col: '_no' },
{ label: 'Symbol', col: 'symbol' },
{ label: 'Sleeve', col: '_sleeve' },
{ label: 'Engine', col: 'engine_id' },
{ label: 'Entry Date', col: 'entry_date' },
{ label: 'Exit Date', col: 'exit_date' },
@ -1455,6 +1606,7 @@ function DirectModeTaskView({
<tr key={i} style={{ borderBottom: i < pageTrades.length - 1 ? '1px solid var(--border)' : 'none', background: i % 2 === 1 ? 'rgba(0,0,0,0.018)' : 'transparent' }}>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)', textAlign: 'right', width: 40 }}>{String(t._no ?? '')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text1)', fontWeight: 500 }}>{String(t.symbol ?? '—')}</td>
<td style={{ padding: '9px 14px' }}><SleeveBadge sleeve={t._sleeve} /></td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{String(t.engine_id ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.entry_date ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.exit_date ?? '—')}</td>
@ -1571,7 +1723,10 @@ export function BacktestResultsPage() {
drawdown: Number(row.drawdown_pct ?? row.drawdown ?? 0),
}));
const trades = (tradesData?.trades ?? []) as Record<string, unknown>[];
const trades = ((tradesData?.trades ?? []) as Record<string, unknown>[]).map(t => ({
...t,
_sleeve: classifyTradeSleeve(t),
})) as (Record<string, unknown> & { _sleeve: TradeSleeve })[];
const totalTrades = (tradesData?.total as number) ?? 0;
const totalPages = Math.ceil(totalTrades / TRADE_PAGE_SIZE);
@ -1738,7 +1893,7 @@ export function BacktestResultsPage() {
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-md)', background: 'var(--bg2)' }}>
{['Symbol', 'Side', 'Entry Date', 'Exit Date', 'Entry $', 'Exit $', 'Return %', 'PnL', 'Exit Reason'].map(h => (
{['Symbol', 'Sleeve', 'Engine', 'Side', 'Entry Date', 'Exit Date', 'Entry $', 'Exit $', 'Return %', 'PnL', 'Exit Reason'].map(h => (
<th key={h} style={{ padding: '11px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 500, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--text3)', textAlign: 'left', whiteSpace: 'nowrap' }}>
{h}
</th>
@ -1751,6 +1906,8 @@ export function BacktestResultsPage() {
return (
<tr key={i} style={{ borderBottom: i < trades.length - 1 ? '1px solid var(--border)' : 'none', background: i % 2 === 1 ? 'rgba(0,0,0,0.018)' : 'transparent' }}>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text1)' }}>{String(t.symbol ?? '—')}</td>
<td style={{ padding: '9px 14px' }}><SleeveBadge sleeve={t._sleeve} /></td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{String(t.engine_id ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: String(t.side ?? t.direction) === 'long' ? 'var(--green)' : 'var(--red)' }}>{String(t.side ?? t.direction ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.entry_date ?? t.open_date ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.exit_date ?? t.close_date ?? '—')}</td>
@ -1838,7 +1995,10 @@ export function BacktestDirectResultsPage() {
equity: row.equity,
}));
const allTrades = (result.trades ?? []) as Record<string, unknown>[];
const allTrades = ((result.trades ?? []) as Record<string, unknown>[]).map(t => ({
...t,
_sleeve: classifyTradeSleeve(t),
})) as (Record<string, unknown> & { _sleeve: TradeSleeve })[];
const totalTrades = allTrades.length;
const totalPages = Math.ceil(totalTrades / TRADE_PAGE_SIZE);
const pageTrades = allTrades.slice(tradePage * TRADE_PAGE_SIZE, (tradePage + 1) * TRADE_PAGE_SIZE);
@ -2000,7 +2160,7 @@ export function BacktestDirectResultsPage() {
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-md)', background: 'var(--bg2)' }}>
{['Symbol', 'Engine', 'Entry Date', 'Exit Date', 'Entry $', 'Exit $', 'PnL', 'Exit Reason'].map(h => (
{['Symbol', 'Sleeve', 'Engine', 'Entry Date', 'Exit Date', 'Entry $', 'Exit $', 'PnL', 'Exit Reason'].map(h => (
<th key={h} style={{ padding: '11px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 500, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--text3)', textAlign: 'left', whiteSpace: 'nowrap' }}>
{h}
</th>
@ -2013,6 +2173,7 @@ export function BacktestDirectResultsPage() {
return (
<tr key={i} style={{ borderBottom: i < pageTrades.length - 1 ? '1px solid var(--border)' : 'none', background: i % 2 === 1 ? 'rgba(0,0,0,0.018)' : 'transparent' }}>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text1)' }}>{String(t.symbol ?? '—')}</td>
<td style={{ padding: '9px 14px' }}><SleeveBadge sleeve={t._sleeve} /></td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{String(t.engine_id ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.entry_date ?? '—')}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{String(t.exit_date ?? '—')}</td>

@ -12,8 +12,10 @@ import {
paperApi,
type PaperSession,
type PaperTask,
type PaperTrade,
} from '../api/client';
import { Loading, ErrorState } from '../components/common/Loading';
import { classifyTradeSleeve, tradeSleeveLabel, type TradeSleeve } from '../lib/tradeSleeves';
import { ansiToHtml } from '../lib/utils';
// ── Shared styles ────────────────────────────────────────────────────────────
@ -119,6 +121,31 @@ function KillSwitchBadge({ on }: { on: boolean }) {
);
}
function SleeveBadge({ sleeve }: { sleeve: TradeSleeve }) {
const tone = sleeve === 'parking'
? { color: 'var(--gold)', bg: 'var(--gold-dim)' }
: sleeve === 'idle_alpha'
? { color: 'var(--cyan)', bg: 'var(--cyan-dim)' }
: { color: 'var(--text2)', bg: 'var(--bg2)' };
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
padding: '2px 8px',
borderRadius: 999,
fontFamily: 'var(--font-mono)',
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.04em',
color: tone.color,
background: tone.bg,
whiteSpace: 'nowrap',
}}>
{tradeSleeveLabel(sleeve)}
</span>
);
}
// ── Stat Card ────────────────────────────────────────────────────────────────
function StatCard({ label, value, sub, valueColor }: {
@ -151,6 +178,8 @@ function CreateSessionModal({ onClose, onCreated }: {
const [selectedConfig, setSelectedConfig] = useState('');
const [capital, setCapital] = useState('10000');
const [parking, setParking] = useState('');
const [idleAlpha, setIdleAlpha] = useState('');
const [form4Sleeve, setForm4Sleeve] = useState('');
const [configOpen, setConfigOpen] = useState(false);
const dropRef = useRef<HTMLDivElement>(null);
@ -165,10 +194,28 @@ function CreateSessionModal({ onClose, onCreated }: {
queryFn: () => paperApi.parkingPresets(),
staleTime: 300_000,
});
const { data: idleAlphaPresetsData } = useQuery({
queryKey: ['idle-alpha-presets'],
queryFn: () => paperApi.idleAlphaPresets(),
staleTime: 300_000,
});
const { data: form4PresetsData } = useQuery({
queryKey: ['form4-capture-presets'],
queryFn: () => paperApi.form4CapturePresets(),
staleTime: 300_000,
});
const qc = useQueryClient();
const { mutate, isPending, error } = useMutation({
mutationFn: () => paperApi.createSession(name.trim(), selectedConfig, parseFloat(capital) || 10000, parking || null),
mutationFn: () =>
paperApi.createSession(
name.trim(),
selectedConfig,
parseFloat(capital) || 10000,
parking || null,
idleAlpha || null,
form4Sleeve || null,
),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['paper-sessions'] });
onCreated({ session_id: data.session_id, name: data.name });
@ -345,6 +392,72 @@ function CreateSessionModal({ onClose, onCreated }: {
)}
</div>
{/* Idle Alpha */}
<div>
<label style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', display: 'block', marginBottom: 6, letterSpacing: '0.06em' }}>
IDLE ALPHA <span style={{ opacity: 0.5 }}>(optional)</span>
</label>
<select
value={idleAlpha}
onChange={e => setIdleAlpha(e.target.value)}
style={{
...inputStyle,
fontSize: 13,
color: idleAlpha ? 'var(--green)' : 'var(--text3)',
cursor: 'pointer',
}}
>
<option value=""> none </option>
{idleAlphaPresetsData
? Object.entries(idleAlphaPresetsData.groups).map(([group, names]) => (
<optgroup key={group} label={group}>
{(names as string[]).map(preset => (
<option key={preset} value={preset}>{preset}</option>
))}
</optgroup>
))
: null}
</select>
{idleAlpha && (
<div style={{ marginTop: 5, fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>
Residual cash can also flow into <span style={{ color: 'var(--green)' }}>{idleAlpha}</span> post-core event engines
</div>
)}
</div>
{/* Form 4 Sleeve */}
<div>
<label style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', display: 'block', marginBottom: 6, letterSpacing: '0.06em' }}>
FORM 4 SLEEVE <span style={{ opacity: 0.5 }}>(optional)</span>
</label>
<select
value={form4Sleeve}
onChange={e => setForm4Sleeve(e.target.value)}
style={{
...inputStyle,
fontSize: 13,
color: form4Sleeve ? 'var(--cyan)' : 'var(--text3)',
cursor: 'pointer',
}}
>
<option value=""> none </option>
{form4PresetsData
? Object.entries(form4PresetsData.groups).map(([group, names]) => (
<optgroup key={group} label={group}>
{(names as string[]).map(preset => (
<option key={preset} value={preset}>{preset}</option>
))}
</optgroup>
))
: null}
</select>
{form4Sleeve && (
<div style={{ marginTop: 5, fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>
Residual cash can also flow into <span style={{ color: 'var(--cyan)' }}>{form4Sleeve}</span> insider cluster entries
</div>
)}
</div>
{error && (
<div style={{ padding: '10px 12px', background: 'var(--red-dim)', borderRadius: 7, fontSize: 13, color: 'var(--red)' }}>
{(error as Error).message}
@ -449,6 +562,26 @@ function SessionItem({ session, selected, onClick }: {
🅿 {session.parking_preset}
</span>
)}
{session.idle_alpha_preset && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
background: 'color-mix(in srgb, var(--green) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--green) 25%, transparent)',
color: 'var(--green)', borderRadius: 3, padding: '1px 5px',
}}>
IA {session.idle_alpha_preset}
</span>
)}
{session.form4_sleeve_preset && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
background: 'color-mix(in srgb, var(--cyan) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--cyan) 25%, transparent)',
color: 'var(--cyan)', borderRadius: 3, padding: '1px 5px',
}}>
F4 {session.form4_sleeve_preset}
</span>
)}
{session.kill_switch && <Shield size={11} style={{ color: 'var(--red)' }} />}
{session.latest_date && (
<span style={{ fontSize: 11, color: 'var(--text3)', marginLeft: 'auto' }}>{session.latest_date}</span>
@ -701,26 +834,47 @@ function PositionsTab({ session }: { session: PaperSession }) {
function TradesTab({ session }: { session: PaperSession }) {
const [lastN, setLastN] = useState<string>('');
const [sleeveFilter, setSleeveFilter] = useState<'' | TradeSleeve>('');
const { data, isLoading, error } = useQuery({
queryKey: ['paper-trades', session.session_id, lastN],
queryFn: () => paperApi.trades(session.session_id, lastN ? parseInt(lastN) : undefined),
});
const trades = data?.trades ?? [];
const wins = trades.filter(t => t.net_pnl > 0).length;
const totalPnl = trades.reduce((s, t) => s + (t.net_pnl ?? 0), 0);
const trades = (data?.trades ?? []) as PaperTrade[];
const sleevedTrades = trades.map(t => ({
...t,
_sleeve: classifyTradeSleeve(t),
})) as (PaperTrade & { _sleeve: TradeSleeve })[];
const filteredTrades = sleeveFilter ? sleevedTrades.filter(t => t._sleeve === sleeveFilter) : sleevedTrades;
const wins = filteredTrades.filter(t => t.net_pnl > 0).length;
const totalPnl = filteredTrades.reduce((s, t) => s + (t.net_pnl ?? 0), 0);
const sleeveOptions: TradeSleeve[] = ['core', 'idle_alpha', 'parking'];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text2)' }}>
{data?.total ?? 0} total trades
{trades.length > 0 && ` · Win Rate: ${((wins / trades.length) * 100).toFixed(0)}%`}
{trades.length > 0 && ` · P&L: `}
{trades.length > 0 && <span style={{ color: pnlColor(totalPnl), fontFamily: 'var(--font-mono)' }}>{fmtMoney(totalPnl, 0)}</span>}
{filteredTrades.length}{filteredTrades.length !== (data?.total ?? 0) ? ` / ${data?.total ?? 0}` : ''} trades
{filteredTrades.length > 0 && ` · Win Rate: ${((wins / filteredTrades.length) * 100).toFixed(0)}%`}
{filteredTrades.length > 0 && ` · P&L: `}
{filteredTrades.length > 0 && <span style={{ color: pnlColor(totalPnl), fontFamily: 'var(--font-mono)' }}>{fmtMoney(totalPnl, 0)}</span>}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<select
value={sleeveFilter}
onChange={e => setSleeveFilter(e.target.value as '' | TradeSleeve)}
style={{
...inputStyle,
width: 130,
padding: '4px 10px',
fontSize: 12,
cursor: 'pointer',
}}
>
<option value="">All Sleeves</option>
{sleeveOptions.map(s => <option key={s} value={s}>{tradeSleeveLabel(s)}</option>)}
</select>
<span style={{ fontSize: 12, color: 'var(--text3)', fontFamily: 'var(--font-mono)' }}>Show last:</span>
{[10, 20, 50, 100].map(n => (
<button
@ -757,29 +911,31 @@ function TradesTab({ session }: { session: PaperSession }) {
{isLoading && <Loading />}
{error && <ErrorState error={error as Error} />}
{!isLoading && trades.length === 0 && (
{!isLoading && filteredTrades.length === 0 && (
<div style={{ ...card, padding: '40px 24px', textAlign: 'center', color: 'var(--text3)', fontSize: 14 }}>
No trades recorded yet
{trades.length === 0 ? 'No trades recorded yet' : 'No trades match the sleeve filter'}
</div>
)}
{trades.length > 0 && (
{filteredTrades.length > 0 && (
<div style={card}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border-md)' }}>
{['Symbol', 'Entry Date', 'Exit Date', 'Entry $', 'Exit $', 'Shares', 'Net P&L', 'R', 'Days', 'Reason'].map(h => (
<th key={h} style={{ padding: '11px 14px', textAlign: h === 'Symbol' || h === 'Reason' ? 'left' : 'right', fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 500, letterSpacing: '0.07em', color: 'var(--text3)', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
{['Symbol', 'Sleeve', 'Engine', 'Entry Date', 'Exit Date', 'Entry $', 'Exit $', 'Shares', 'Net P&L', 'R', 'Days', 'Reason'].map(h => (
<th key={h} style={{ padding: '11px 14px', textAlign: h === 'Symbol' || h === 'Sleeve' || h === 'Engine' || h === 'Reason' ? 'left' : 'right', fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 500, letterSpacing: '0.07em', color: 'var(--text3)', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{trades.map((t, i) => (
{filteredTrades.map((t, i) => (
<tr key={t.trade_id} style={{ borderBottom: '1px solid var(--border)', background: i % 2 === 1 ? 'rgba(0,0,0,0.015)' : 'transparent' }}>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontWeight: 700, color: 'var(--text1)' }}>{t.symbol}</td>
<td style={{ padding: '9px 14px', textAlign: 'left' }}><SleeveBadge sleeve={t._sleeve} /></td>
<td style={{ padding: '9px 14px', textAlign: 'left', fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text3)' }}>{t.engine_id ?? '—'}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{t.entry_date ?? '—'}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{t.exit_date}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text2)' }}>{fmtPrice(t.entry_price)}</td>
@ -787,9 +943,9 @@ function TradesTab({ session }: { session: PaperSession }) {
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{t.shares}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', fontWeight: 600, color: pnlColor(t.net_pnl) }}>{fmtMoney(t.net_pnl)}</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: pnlColor(t.r_multiple) }}>
{t.r_multiple >= 0 ? '+' : ''}{t.r_multiple.toFixed(2)}R
{t.r_multiple != null ? `${t.r_multiple >= 0 ? '+' : ''}${t.r_multiple.toFixed(2)}R` : '—'}
</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{t.holding_days}d</td>
<td style={{ padding: '9px 14px', textAlign: 'right', fontFamily: 'var(--font-mono)', color: 'var(--text3)' }}>{t.holding_days != null ? `${t.holding_days}d` : '—'}</td>
<td style={{ padding: '9px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text3)' }}>{t.exit_reason}</td>
</tr>
))}
@ -1142,6 +1298,26 @@ function SessionDetail({ session, onRefresh }: {
🅿 {session.parking_preset}
</span>
)}
{session.idle_alpha_preset && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
background: 'color-mix(in srgb, var(--green) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--green) 25%, transparent)',
color: 'var(--green)', borderRadius: 4, padding: '2px 8px',
}}>
IA {session.idle_alpha_preset}
</span>
)}
{session.form4_sleeve_preset && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 11,
background: 'color-mix(in srgb, var(--cyan) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--cyan) 25%, transparent)',
color: 'var(--cyan)', borderRadius: 4, padding: '2px 8px',
}}>
F4 {session.form4_sleeve_preset}
</span>
)}
</div>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text3)', marginTop: 3 }}>
{session.config_path} · ID: {session.session_id} · Created {session.created_at.slice(0, 10)}

@ -22,8 +22,10 @@ class ExitReason(str, Enum):
STOP = "STOP"
TARGET = "TARGET"
TIME = "TIME"
DECAY = "DECAY"
TRAILING = "TRAILING"
KILL_SWITCH = "KILL_SWITCH"
END_OF_BACKTEST = "END_OF_BACKTEST"
MISSING_BAR = "MISSING_BAR"
NO_FOLLOW_THROUGH = "NO_FOLLOW_THROUGH"
EARLY_FAILURE = "EARLY_FAILURE"
@ -32,6 +34,7 @@ class ExitReason(str, Enum):
RECYCLE = "RECYCLE"
ROTATION = "ROTATION"
PARKING = "PARKING"
DIVIDEND_CAPTURE = "DIVIDEND_CAPTURE"
class BacktestMode(str, Enum):
@ -45,6 +48,7 @@ class Candidate(BaseModel):
event_id: str
symbol: str
source_symbol: str | None = None
issuer_id: str | None = None
score: float
sector: str # "UNKNOWN" if unavailable
@ -69,7 +73,21 @@ class Candidate(BaseModel):
engine_max_position_value_pct: float | None = None
engine_max_adv_fraction: float | None = None
engine_risk_budget_pct: float = 1.0
engine_capital_bucket_id: str | None = None
engine_capital_bucket_allocation_pct: float | None = None
engine_per_trade_risk_pct: float | None = None
engine_macro_vix_size_scaler_low: float | None = None
engine_macro_vix_size_scaler_high: float | None = None
engine_macro_vix_size_scaler_min: float | None = None
engine_macro_hy_spread_size_scaler_low: float | None = None
engine_macro_hy_spread_size_scaler_high: float | None = None
engine_macro_hy_spread_size_scaler_min: float | None = None
engine_score_size_scaler_low: float | None = None
engine_score_size_scaler_high: float | None = None
engine_score_size_scaler_min: float | None = None
engine_entropy_size_scaler_low: float | None = None
engine_entropy_size_scaler_high: float | None = None
engine_entropy_size_scaler_min: float | None = None
engine_target_atr_multiplier: float | None = None
engine_stop_atr_multiplier: float | None = None
engine_target_1_r: float | None = None
@ -93,7 +111,9 @@ class Candidate(BaseModel):
engine_next_open_gap_cap_pct: float | None = None
engine_add_on_max_count: int | None = None
engine_add_on_size_fraction: float | None = None
trade_symbol_mode: str = "event" # "event", "sector_etf", "peer_proxy"
trade_direction: str = "long" # "long" or "short"
engine_forced_trade_direction: str | None = None # explicit engine override, e.g. contrarian long on bearish
parent_position_id: str | None = None
is_add_on: bool = False
forced_shares: int | None = None
@ -128,6 +148,7 @@ class FilledTrade(BaseModel):
position_id: str
event_id: str
symbol: str
source_symbol: str | None = None
event_date: dt.date | None = None
event_type: str = ""
score: float = 0.0
@ -137,6 +158,7 @@ class FilledTrade(BaseModel):
shadow_only: bool = False
parent_position_id: str | None = None
is_add_on: bool = False
trade_symbol_mode: str = "event"
entry_date: dt.date
exit_date: dt.date
entry_price: float
@ -534,6 +556,48 @@ PARKING_PRESETS: dict[str, dict] = {
"cash_parking_topup_risk_score_max": 25.0,
"cash_parking_reserve_pct": 0.0,
},
"qqqm_low_dd_tqqq_calm": {
"cash_parking_enabled": True,
"cash_parking_symbol": "qqqm",
"cash_parking_gate_mode": "volatility",
"cash_parking_gate_vol_lookback": 20,
"cash_parking_gate_vol_threshold": 0.275,
"cash_parking_temperature_threshold": 1.2,
"cash_parking_entropy_lookback": 20,
"cash_parking_entropy_threshold": 1.45,
"cash_parking_require_trend": True,
"cash_parking_trend_mode": "momentum",
"cash_parking_trend_sma_period": 20,
"cash_parking_trend_reentry_pct": 0.001,
"cash_parking_autocorr_threshold": 0.0,
"cash_parking_topup_max_peak_drawdown_pct": 0.02,
"cash_parking_reserve_pct": 0.0,
"cash_parking_low_vol_overlay_symbol": "tqqq",
"cash_parking_low_vol_overlay_vol_threshold": 0.17,
"cash_parking_low_vol_overlay_temperature_max": 0.92,
"cash_parking_low_vol_overlay_entropy_max": 1.15,
},
"qqqm_low_dd_tqqq_active": {
"cash_parking_enabled": True,
"cash_parking_symbol": "qqqm",
"cash_parking_gate_mode": "volatility",
"cash_parking_gate_vol_lookback": 20,
"cash_parking_gate_vol_threshold": 0.275,
"cash_parking_temperature_threshold": 1.2,
"cash_parking_entropy_lookback": 20,
"cash_parking_entropy_threshold": 1.45,
"cash_parking_require_trend": True,
"cash_parking_trend_mode": "momentum",
"cash_parking_trend_sma_period": 20,
"cash_parking_trend_reentry_pct": 0.001,
"cash_parking_autocorr_threshold": 0.0,
"cash_parking_topup_max_peak_drawdown_pct": 0.02,
"cash_parking_reserve_pct": 0.0,
"cash_parking_low_vol_overlay_symbol": "tqqq",
"cash_parking_low_vol_overlay_vol_threshold": 0.22,
"cash_parking_low_vol_overlay_temperature_max": 1.05,
"cash_parking_low_vol_overlay_entropy_max": 1.30,
},
# ── Bearish Parking (SH inverse ETF) ────────────────────────────────────
# Two-stage: normal→QQQ, mild stress→SGOV, deep stress→SH
# Uses composite risk score: < exit_score=QQQ, exit_score→SGOV, > bearish_threshold→SH
@ -584,6 +648,312 @@ PARKING_PRESETS: dict[str, dict] = {
"cash_parking_bearish_symbol": "sh",
"cash_parking_bearish_threshold": 60,
},
# ── Multi-tier Regime Parking ────────────────────────────────────────────
# VIX-driven 3-tier rotation: Risk-On→JEPQ, Neutral→QQQM, Risk-Off→SGOV
# Requires JEPQ price data in Oracle. JEPQ dividend income not modeled in backtest.
"regime_tiered_jepq": {
"cash_parking_enabled": True,
"cash_parking_symbol": "qqqm", # default fallback symbol
"cash_parking_gate_mode": "regime_tiered",
"cash_parking_regime_risk_on_symbol": "jepq",
"cash_parking_regime_neutral_symbol": "qqqm",
"cash_parking_regime_vix_low_threshold": 20.0,
"cash_parking_regime_vix_high_threshold": 25.0,
"cash_parking_regime_hysteresis_buffer": 1.0,
"cash_parking_reserve_pct": 0.02,
"cash_parking_stop_pct": 0.045,
"cash_parking_stop_recovery_days": 5,
"cash_parking_stop_recovery_pct": 0.02,
},
}
# ---------------------------------------------------------------------------
# Named idle-alpha sleeve presets — referenced by BacktestConfig
# ---------------------------------------------------------------------------
_MICRO_EVENT_ALPHA_ENGINES: list[dict[str, Any]] = [
{
"engine_id": "next_open_long_material_contract_mixed_micro_postmarket",
"selection_priority": -1,
"event_types": ["material_contract"],
"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.165,
"reaction_day_return_min": -0.01,
"reaction_day_return_max": 0.04,
"close_location_min": 0.6,
"close_location_max": 1.0,
"gap_size_min": -0.02,
"gap_size_max": 0.02,
"volume_ratio_min": 0.5,
"volume_ratio_max": 1.9,
"max_market_cap_proxy": 15000000000.0,
"document_quality_score_min": 0.5,
"parse_confidence_overall_min": 0.45,
"score_threshold_override": 0.0,
"residual_reserve_selected": True,
"post_allocation_idle_only": True,
"next_open_gap_cap_pct": 0.02,
"early_failure_close_below_entry_and_reaction_close_override": False,
"early_failure_no_progress_days_override": 12,
"early_failure_no_progress_r_override": 0.0,
"early_failure_no_progress_fraction_override": 1.0,
"target_1_r_override": 5.0,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 12,
"enabled": True,
"veto_parse_confidence_min_override": 0.45,
"per_trade_risk_pct_override": 0.12375,
"stop_atr_multiplier_override": 3.0,
"use_reaction_day_low_stop_override": False,
"pre_event_entropy_60d_max": 2.05,
"pre_event_market_temperature_max": 1.85,
"macro_vix_max": 30.0,
},
{
"engine_id": "next_open_long_guidance_mixed_micro_postmarket",
"selection_priority": -1,
"event_types": ["guidance_update"],
"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.12375,
"per_trade_risk_pct_override": 0.1155,
"reaction_day_return_min": -0.02,
"reaction_day_return_max": 0.05,
"close_location_min": 0.55,
"close_location_max": 1.0,
"gap_size_min": -0.02,
"gap_size_max": 0.025,
"volume_ratio_min": 0.5,
"volume_ratio_max": 2.3,
"max_market_cap_proxy": 15000000000.0,
"document_quality_score_min": 0.5,
"parse_confidence_overall_min": 0.45,
"score_threshold_override": 0.0,
"residual_reserve_selected": True,
"post_allocation_idle_only": True,
"next_open_gap_cap_pct": 0.025,
"early_failure_close_below_entry_and_reaction_close_override": False,
"early_failure_no_progress_days_override": 12,
"early_failure_no_progress_r_override": 0.0,
"early_failure_no_progress_fraction_override": 1.0,
"target_1_r_override": 5.0,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 12,
"enabled": True,
"veto_parse_confidence_min_override": 0.45,
"stop_atr_multiplier_override": 3.0,
"use_reaction_day_low_stop_override": False,
"pre_event_entropy_60d_max": 1.7,
"pre_event_market_temperature_max": 0.8,
"pre_event_gravitational_pull_min": 1.0,
"macro_vix_max": 30.0,
},
{
"engine_id": "next_open_long_earnings_unknown_inline_postmarket_strict",
"selection_priority": -1,
"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.12375,
"per_trade_risk_pct_override": 0.066,
"reaction_day_return_min": -0.02,
"reaction_day_return_max": 0.04,
"close_location_min": 0.1,
"close_location_max": 1.0,
"gap_size_min": -0.05,
"gap_size_max": 0.05,
"volume_ratio_min": 1.2,
"volume_ratio_max": 3.0,
"min_market_cap_proxy": 4000000000.0,
"max_market_cap_proxy": 15000000000.0,
"document_quality_score_min": 0.5,
"parse_confidence_overall_min": 0.45,
"score_threshold_override": 0.0,
"residual_reserve_selected": True,
"post_allocation_idle_only": True,
"veto_parse_confidence_min_override": 0.45,
"next_open_gap_cap_pct": 0.05,
"early_failure_close_below_entry_and_reaction_close_override": False,
"early_failure_no_progress_days_override": 10,
"early_failure_no_progress_r_override": 0.0,
"early_failure_no_progress_fraction_override": 1.0,
"target_1_r_override": 5.0,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 10,
"enabled": True,
"stop_atr_multiplier_override": 3.0,
"use_reaction_day_low_stop_override": False,
"pre_event_entropy_60d_max": 1.9,
"pre_event_market_temperature_max": 1.1,
"macro_vix_max": 30.0,
},
{
"engine_id": "next_open_long_bullish_raised_strong",
"selection_priority": -1,
"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": 20,
"engine_risk_budget_pct": 0.165,
"reaction_day_return_min": 0.03,
"reaction_day_return_max": 0.2,
"close_location_min": 0.96,
"volume_ratio_min": 1.2,
"volume_ratio_max": 6.0,
"min_market_cap_proxy": 5000000000.0,
"document_quality_score_min": 0.6,
"parse_confidence_overall_min": 0.6,
"score_threshold_override": 0.0,
"residual_reserve_selected": True,
"post_allocation_idle_only": True,
"next_open_gap_cap_pct": 0.1,
"early_failure_close_below_entry_and_reaction_close_override": False,
"early_failure_no_progress_days_override": 12,
"early_failure_no_progress_r_override": 0.0,
"early_failure_no_progress_fraction_override": 1.0,
"target_1_r_override": 5.0,
"target_1_fraction_override": 0.1,
"trailing_warmup_days_override": 10,
"enabled": True,
"per_trade_risk_pct_override": 0.12375,
"stop_atr_multiplier_override": 3.0,
"use_reaction_day_low_stop_override": False,
"pre_event_entropy_60d_min": 1.97,
},
]
_MICRO_EVENT_ALPHA_BREADTH_ENGINE: dict[str, Any] = {
"engine_id": "idle_macro_breadth_smh_postalloc",
"selection_priority": -2,
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"max_holding_days": 2,
"engine_risk_budget_pct": 0.022,
"per_trade_risk_pct_override": 0.0033,
"stop_atr_multiplier_override": 1.9,
"target_1_r_override": 99.0,
"target_1_fraction_override": 0.0,
"trailing_warmup_days_override": 1,
"early_failure_close_below_entry_and_reaction_close_override": False,
"next_open_gap_cap_pct": 0.018,
"macro_long_symbol": "SMH",
"macro_long_trade_symbol_mode": "fixed",
"macro_long_reaction_day_return_min": 0.018,
"macro_long_volume_ratio_min": 1.1,
"macro_long_gap_size_min": 0.0,
"macro_long_close_location_min": 0.64,
"macro_long_breadth_symbols": ["QQQ", "XLK", "SMH"],
"macro_long_min_breadth_count": 2,
"macro_long_breadth_reaction_day_return_min": 0.01,
"macro_long_breadth_close_location_min": 0.6,
"macro_long_leadership_vs_spy_min": 0.004,
"macro_vix_max": 27.0,
"enabled": True,
"synthetic_only": True,
"post_allocation_idle_only": True,
}
IDLE_ALPHA_SLEEVE_PRESETS: dict[str, dict[str, Any]] = {
"micro_event_alpha": {
"strategy_engines": [
*(_MICRO_EVENT_ALPHA_ENGINES),
],
},
"micro_event_alpha_plus": {
"strategy_engines": [
*(_MICRO_EVENT_ALPHA_ENGINES),
{**_MICRO_EVENT_ALPHA_BREADTH_ENGINE},
],
"idle_alpha": {
"dynamic_allocator_enabled": True,
"dynamic_allocator_cash_ratio_low": 0.04,
"dynamic_allocator_cash_ratio_high": 0.16,
"dynamic_allocator_cash_scale_low": 0.8,
"dynamic_allocator_cash_scale_high": 1.025,
"dynamic_allocator_crowded_primary_candidate_count": 6,
"dynamic_allocator_crowded_primary_unique_sector_count": 4,
"dynamic_allocator_crowded_scale": 0.79,
"dynamic_allocator_synthetic_scale_multiplier": 1.03,
"dynamic_allocator_snapshot_scale_multiplier": 1.0,
"dynamic_allocator_synthetic_reentry_cooldown_days": 2,
"dynamic_allocator_min_scale": 0.6,
"dynamic_allocator_max_scale": 1.045,
},
},
"micro_event_alpha_plus_event_plus": {
"strategy_engines": [
*(_MICRO_EVENT_ALPHA_ENGINES),
{**_MICRO_EVENT_ALPHA_BREADTH_ENGINE},
],
"idle_alpha": {
"dynamic_allocator_enabled": True,
"dynamic_allocator_cash_ratio_low": 0.04,
"dynamic_allocator_cash_ratio_high": 0.16,
"dynamic_allocator_cash_scale_low": 0.8,
"dynamic_allocator_cash_scale_high": 1.025,
"dynamic_allocator_crowded_primary_candidate_count": 6,
"dynamic_allocator_crowded_primary_unique_sector_count": 4,
"dynamic_allocator_crowded_scale": 0.79,
"dynamic_allocator_synthetic_scale_multiplier": 1.03,
"dynamic_allocator_snapshot_scale_multiplier": 1.02,
"dynamic_allocator_synthetic_reentry_cooldown_days": 2,
"dynamic_allocator_min_scale": 0.6,
"dynamic_allocator_max_scale": 1.045,
},
},
}
# ---------------------------------------------------------------------------
# Named dividend-capture sleeve presets — referenced by BacktestConfig
# ---------------------------------------------------------------------------
DIVIDEND_CAPTURE_SLEEVE_PRESETS: dict[str, dict[str, Any]] = {
"reserve_dividend_capture": {
"enabled": True,
"pit_calendar_path": "data/reference/dividend_calendar_pit.parquet",
"reserve_pct": 0.05,
"min_dividend_yield_pct": 0.0025,
"max_dividend_yield_pct": 0.02,
"min_avg_dollar_volume": 20_000_000.0,
"max_positions": 5,
},
}
FORM4_CAPTURE_SLEEVE_PRESETS: dict[str, dict[str, Any]] = {
"reserve_form4_cluster": {
"enabled": True,
"pit_events_path": "data/reference/form4_daily_events_pit.parquet",
"reserve_pct": 0.04,
"min_owner_count": 2,
"min_total_value": 5_000_000.0,
"min_purchase_pct": 0.0,
"max_lag_days": None,
"hold_days": 20,
"max_positions": 6,
"max_new_per_day": 2,
},
}
@ -668,8 +1038,8 @@ class RiskConfig(BaseModel):
cash_parking_enabled: bool = False # park idle cash in index when no event positions
cash_parking_preset: str | None = None # named preset (overrides all other parking params)
cash_parking_account_type: str = "cash" # "cash" (no PDT, GFV only) or "margin" (PDT day trade rules)
cash_parking_symbol: str = "spy" # "spy", "spym", "qqq", "qqqm", "dynamic", "sgov"
cash_parking_defensive_symbol: str = "spy" # fallback defensive ETF for pair/regime parking ("spy", "spym")
cash_parking_symbol: str = "spy" # "spy", "spym", "qual", "qqq", "qqqm", "dynamic", "sgov"
cash_parking_defensive_symbol: str = "spy" # fallback defensive ETF for pair/regime parking ("spy", "spym", "qual")
cash_parking_defensive_relay_enabled: bool = False # when primary gate says SGOV, allow defensive ETF instead if it is still healthy
cash_parking_defensive_relay_trigger_mode: str = "always" # "always", "turn_of_month", "recovery", "turn_or_recovery"
cash_parking_defensive_relay_turn_strength_min: float = 0.0 # minimum month-turn strength to allow relay
@ -680,7 +1050,7 @@ class RiskConfig(BaseModel):
cash_parking_reserve_pct: float = 0.02 # keep this % of equity as cash reserve
cash_parking_trend_gate: bool = False # only park when price > SMA (skip downtrends)
cash_parking_gate_sma_period: int = 20 # SMA period for trend gate (10, 20, 50, etc.)
cash_parking_gate_mode: str = "price_above" # "price_above", "dual_sma", "drawdown", "momentum", "pct_threshold", "combo", "hysteresis", "slope", "breakout", "volatility", "recovery", "vol_trend", "vol_dd", "vol_regime", "guarded_regime", "composite", "science_regime", "science_blend", "relative_strength", "vt_blend", "vt_pair_blend"
cash_parking_gate_mode: str = "price_above" # "price_above", "dual_sma", "drawdown", "momentum", "pct_threshold", "combo", "hysteresis", "slope", "breakout", "volatility", "recovery", "vol_trend", "vol_dd", "vol_regime", "guarded_regime", "composite", "science_regime", "science_blend", "relative_strength", "vt_blend", "vt_pair_blend", "regime_tiered"
cash_parking_composite_exit_score: int = 40 # risk score >= this → SGOV
cash_parking_composite_enter_score: int = 20 # risk score <= this → QQQ (hysteresis)
cash_parking_composite_spy_score: int = 28 # science_regime: mid-risk parking goes to defensive ETF below this score
@ -758,6 +1128,12 @@ class RiskConfig(BaseModel):
# None = disabled (default, existing behavior unchanged).
cash_parking_bearish_symbol: str | None = None # "sh", "sds", etc. — inverse ETF for deep stress
cash_parking_bearish_threshold: int = 60 # composite risk score >= this → use bearish_symbol
# Multi-tier regime parking: VIX-driven 3-symbol rotation (used with gate_mode="regime_tiered")
cash_parking_regime_risk_on_symbol: str = "jepq" # symbol when VIX < vix_low_threshold
cash_parking_regime_neutral_symbol: str = "qqqm" # symbol when VIX between thresholds
cash_parking_regime_vix_low_threshold: float = 20.0 # VIX below this → risk-on symbol
cash_parking_regime_vix_high_threshold: float = 25.0 # VIX above this → SGOV
cash_parking_regime_hysteresis_buffer: float = 1.0 # extra VIX margin to prevent whipsaw on tier transitions
# HY Credit spread size scaler (uses macro_hy_spread from candidate features)
credit_spread_size_scaler_enabled: bool = False
@ -812,6 +1188,10 @@ class ExecutionConfig(BaseModel):
early_pop_giveback_min_r: float | None = None
early_pop_giveback_from_peak_pct: float | None = None
early_pop_giveback_fraction: float | None = None
expected_decay_exit_enabled: bool = False
expected_decay_lambda: float | None = None
expected_decay_score_floor: float | None = None
expected_decay_min_days_held: int = 1
dynamic_hold_enabled: bool = False # adaptive mhd: extend for winners, cut losers early
dynamic_hold_checkpoints: list[tuple[int, float]] | None = None # [(day, min_r), ...] cut if R below threshold
dynamic_hold_extend_day: int = 8 # check day for extending mhd
@ -833,6 +1213,8 @@ class StrategyEngineConfig(BaseModel):
engine_id: str
inherits_from_engine_id: str | None = None
exclude_if_matches_engine_id: str | None = None
selection_priority: int = 0
ranking_fields_override: list[str] | None = None
event_types: list[str] = Field(default_factory=list)
entry_conventions: list[str] | None = None
allowed_macro_regimes: list[str] | None = None
@ -840,16 +1222,33 @@ class StrategyEngineConfig(BaseModel):
guidance_statuses: list[str] | None = None
filing_time_buckets: list[str] | None = None
allowed_exchanges: list[str] | None = None
allowed_sectors: list[str] | None = None
excluded_symbols: list[str] | None = None
timing_class: str = "any" # "same_day", "after_close", "any"
direction: str = "any" # "long_only", "short_only", "any"
forced_trade_direction_override: str | None = None # "long" or "short"
trade_symbol_mode: str = "event" # "event", "sector_etf", "peer_proxy"
entry_timing_policy: str = "next_open" # "next_open", "reaction_close"
max_holding_days: int | None = None
max_positions_per_sector_override: int | None = None
max_position_value_pct_override: float | None = None
max_adv_fraction_override: float | None = None
engine_risk_budget_pct: float = 1.0
capital_bucket_id: str | None = None
capital_bucket_allocation_pct: float | None = None
per_trade_risk_pct_override: float | None = None
macro_vix_size_scaler_low: float | None = None
macro_vix_size_scaler_high: float | None = None
macro_vix_size_scaler_min: float | None = None
macro_hy_spread_size_scaler_low: float | None = None
macro_hy_spread_size_scaler_high: float | None = None
macro_hy_spread_size_scaler_min: float | None = None
score_size_scaler_low: float | None = None
score_size_scaler_high: float | None = None
score_size_scaler_min: float | None = None
entropy_size_scaler_low: float | None = None
entropy_size_scaler_high: float | None = None
entropy_size_scaler_min: float | None = None
stop_atr_multiplier_override: float | None = None
target_atr_multiplier_override: float | None = None
target_1_r_override: float | None = None
@ -899,6 +1298,16 @@ class StrategyEngineConfig(BaseModel):
avg_dollar_volume_max: float | None = None
gap_size_min: float | None = None
gap_size_max: float | None = None
proxy_reaction_day_return_min: float | None = None
proxy_reaction_day_return_max: float | None = None
proxy_close_location_min: float | None = None
proxy_close_location_max: float | None = None
proxy_volume_ratio_min: float | None = None
proxy_volume_ratio_max: float | None = None
proxy_gap_size_min: float | None = None
proxy_gap_size_max: float | None = None
proxy_avg_dollar_volume_min: float | None = None
proxy_avg_dollar_volume_max: float | None = None
reaction_day_range_pct_min: float | None = None
reaction_day_range_pct_max: float | None = None
upper_wick_pct_min: float | None = None
@ -909,16 +1318,63 @@ class StrategyEngineConfig(BaseModel):
document_quality_score_max: float | None = None
signal_strength_score_min: float | None = None
signal_strength_score_max: float | None = None
oneoff_penalty_min: float | None = None
oneoff_penalty_max: float | None = None
parse_confidence_overall_min: float | None = None
parse_confidence_overall_max: float | None = None
prior_event_fwd5d_min: float | None = None
prior_event_fwd5d_max: float | None = None
lm_net_sentiment_min: float | None = None
lm_net_sentiment_max: float | None = None
earnings_surprise_pct_min: float | None = None
earnings_surprise_pct_max: float | None = None
peer_sector_event_count_365d_min: float | None = None
peer_sector_event_count_365d_max: float | None = None
sector_recent_event_count_3d_min: float | None = None
sector_recent_event_count_3d_max: float | None = None
sector_recent_leader_count_3d_min: float | None = None
sector_recent_leader_count_3d_max: float | None = None
sector_recent_leader_reaction_max_3d_min: float | None = None
sector_recent_leader_reaction_max_3d_max: float | None = None
peer_relative_surprise_pct_365d_min: float | None = None
peer_relative_surprise_pct_365d_max: float | None = None
peer_relative_sue_hist_mean_4q_365d_min: float | None = None
peer_relative_sue_hist_mean_4q_365d_max: float | None = None
prior_catalyst_count_20d_min: float | None = None
prior_catalyst_count_20d_max: float | None = None
prior_catalyst_count_60d_min: float | None = None
prior_catalyst_count_60d_max: float | None = None
prior_catalyst_type_diversity_20d_min: float | None = None
prior_catalyst_type_diversity_20d_max: float | None = None
prior_catalyst_type_diversity_60d_min: float | None = None
prior_catalyst_type_diversity_60d_max: float | None = None
sentiment_surprise_min: float | None = None
sentiment_surprise_max: float | None = None
price_text_dislocation_min: float | None = None
price_text_dislocation_max: float | None = None
positive_price_text_dislocation_min: float | None = None
positive_price_text_dislocation_max: float | None = None
positive_price_text_dislocation_rank_min: float | None = None
positive_price_text_dislocation_rank_max: float | None = None
macro_vix_min: float | None = None
macro_vix_max: float | None = None
volatility_crush_only: bool = False
volatility_crush_vix_drop_pct_min: float | None = None
volatility_crush_spy_return_min: float | None = None
volatility_crush_score_threshold_override: float | None = None
volatility_crush_per_trade_risk_pct_override: float | None = None
volatility_crush_engine_risk_budget_pct_override: float | None = None
volatility_crush_macro_vix_max_override: float | None = None
macro_hy_spread_min: float | None = None
macro_hy_spread_max: float | None = None
pre_event_hurst_60d_min: float | None = None
pre_event_hurst_60d_max: float | None = None
pre_event_entropy_60d_min: float | None = None
pre_event_entropy_60d_max: float | None = None
pre_event_short_ratio_min: float | None = None
pre_event_short_ratio_max: float | None = None
pre_event_sector_momentum_20d_min: float | None = None
pre_event_sector_momentum_20d_max: float | None = None
pre_event_bb_position_min: float | None = None
pre_event_bb_position_max: float | None = None
pre_event_gravitational_pull_min: float | None = None
@ -960,6 +1416,13 @@ class StrategyEngineConfig(BaseModel):
delayed_entry_source_engine_ids: list[str] | None = None # which engines' candidates to consider
delayed_entry_min_drift_pct: float | None = None # min price change since reaction close
delayed_entry_close_location_min: float | None = None # today's close location requirement
leader_follower_lookahead_days: int | None = None # trading-day window to upcoming follower earnings
leader_follower_min_days_to_event: int | None = None # minimum trading days until follower event
leader_follower_hold_buffer_days: int = 1 # exit before follower event by this many trading days
leader_follower_calendar_mode: str = "future_row" # "future_row", "pit_calendar", "pit_then_fallback"
leader_follower_extra_peer_symbols_by_leader: dict[str, list[str]] | None = None
leader_follower_extra_peer_symbols_by_sector: dict[str, list[str]] | None = None
leader_follower_allowed_peer_symbols: list[str] | None = None
attention_min_wiki_spike_10d: float | None = None
attention_min_wiki_zscore_20d: float | None = None
attention_max_wiki_spike_10d: float | None = None
@ -969,9 +1432,34 @@ class StrategyEngineConfig(BaseModel):
attention_min_resolver_confidence: float | None = None
shadow_only: bool = False
synthetic_only: bool = False
post_allocation_idle_only: bool = False
enabled: bool = True
# Macro short engine: generate SH candidate when composite risk score >= threshold
macro_short_risk_threshold: int | None = None
# Macro long engine: generate ETF long candidate when leadership/breadth trigger fires
macro_long_symbol: str | None = None
macro_long_reaction_day_return_min: float | None = None
macro_long_reaction_day_return_max: float | None = None
macro_long_volume_ratio_min: float | None = None
macro_long_volume_ratio_max: float | None = None
macro_long_gap_size_min: float | None = None
macro_long_gap_size_max: float | None = None
macro_long_close_location_min: float | None = None
macro_long_close_location_max: float | None = None
macro_long_trade_symbol_mode: str | None = None
macro_long_breadth_symbols: list[str] | None = None
macro_long_min_breadth_count: int | None = None
macro_long_breadth_reaction_day_return_min: float | None = None
macro_long_breadth_reaction_day_return_max: float | None = None
macro_long_breadth_volume_ratio_min: float | None = None
macro_long_breadth_volume_ratio_max: float | None = None
macro_long_breadth_gap_size_min: float | None = None
macro_long_breadth_gap_size_max: float | None = None
macro_long_breadth_close_location_min: float | None = None
macro_long_breadth_close_location_max: float | None = None
macro_long_leadership_vs_spy_min: float | None = None
macro_long_min_daily_candidate_count: int | None = None
macro_long_min_unique_sector_count: int | None = None
class EventTypeProfile(BaseModel):
@ -992,20 +1480,123 @@ class ReportingConfig(BaseModel):
attribution_buckets: list[str] = Field(default_factory=list)
class DividendCaptureConfig(BaseModel):
enabled: bool = False
pit_calendar_path: str | None = None
reserve_pct: float = 0.0
min_dividend_yield_pct: float = 0.0025
max_dividend_yield_pct: float | None = 0.02
min_avg_dollar_volume: float = 20_000_000.0
max_positions: int = 5
class Form4CaptureConfig(BaseModel):
enabled: bool = False
pit_events_path: str | None = None
reserve_pct: float = 0.0
min_owner_count: int = 2
min_total_value: float = 5_000_000.0
min_purchase_pct: float = 0.0
max_lag_days: int | None = None
hold_days: int = 20
max_positions: int = 6
max_new_per_day: int = 2
class IdleAlphaConfig(BaseModel):
dynamic_allocator_enabled: bool = False
dynamic_allocator_cash_ratio_low: float = 0.04
dynamic_allocator_cash_ratio_high: float = 0.16
dynamic_allocator_cash_scale_low: float = 0.7
dynamic_allocator_cash_scale_high: float = 1.1
dynamic_allocator_crowded_primary_candidate_count: int | None = None
dynamic_allocator_crowded_primary_unique_sector_count: int | None = None
dynamic_allocator_crowded_scale: float = 0.85
dynamic_allocator_synthetic_scale_multiplier: float = 1.0
dynamic_allocator_snapshot_scale_multiplier: float = 1.0
dynamic_allocator_synthetic_reentry_cooldown_days: int = 0
dynamic_allocator_min_scale: float = 0.55
dynamic_allocator_max_scale: float = 1.2
class BacktestConfig(BaseModel):
strategy_name: str
dataset_snapshot_id: str
requested_snapshot_id: str | None = None
canonical_snapshot_id: str | None = None
earnings_calendar_pit_path: str | None = None
dividend_capture_sleeve_preset: str | None = None
form4_capture_sleeve_preset: str | None = None
universe: UniverseConfig = Field(default_factory=UniverseConfig)
signal: SignalConfig = Field(default_factory=SignalConfig)
risk: RiskConfig = Field(default_factory=RiskConfig)
execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
reporting: ReportingConfig = Field(default_factory=ReportingConfig)
dividend_capture: DividendCaptureConfig = Field(default_factory=DividendCaptureConfig)
form4_capture: Form4CaptureConfig = Field(default_factory=Form4CaptureConfig)
idle_alpha: IdleAlphaConfig = Field(default_factory=IdleAlphaConfig)
event_type_profiles: dict[str, EventTypeProfile] = Field(default_factory=dict)
idle_alpha_sleeve_preset: str | None = None
strategy_engines: list[StrategyEngineConfig] = Field(default_factory=list)
strategy_engine_selection_mode: str = "interleave" # "interleave", "interleave_head_score", "global_score", "interleave_cap_efficiency_soft", "interleave_cap_efficiency_strict", or "interleave_cash_tiebreak"
def model_post_init(self, __context: Any) -> None:
self.risk.apply_parking_preset()
self.apply_dividend_capture_sleeve_preset()
self.apply_form4_capture_sleeve_preset()
self.apply_idle_alpha_sleeve_preset()
def apply_dividend_capture_sleeve_preset(self) -> None:
if not self.dividend_capture_sleeve_preset:
return
preset = DIVIDEND_CAPTURE_SLEEVE_PRESETS.get(self.dividend_capture_sleeve_preset)
if preset is None:
raise ValueError(
"Unknown dividend capture sleeve preset: "
f"{self.dividend_capture_sleeve_preset}. Available: {list(DIVIDEND_CAPTURE_SLEEVE_PRESETS.keys())}"
)
current = self.dividend_capture.model_dump()
current.update(preset)
self.dividend_capture = DividendCaptureConfig.model_validate(current)
def apply_form4_capture_sleeve_preset(self) -> None:
if not self.form4_capture_sleeve_preset:
return
preset = FORM4_CAPTURE_SLEEVE_PRESETS.get(self.form4_capture_sleeve_preset)
if preset is None:
raise ValueError(
"Unknown Form 4 capture sleeve preset: "
f"{self.form4_capture_sleeve_preset}. Available: {list(FORM4_CAPTURE_SLEEVE_PRESETS.keys())}"
)
current = self.form4_capture.model_dump()
current.update(preset)
self.form4_capture = Form4CaptureConfig.model_validate(current)
def apply_idle_alpha_sleeve_preset(self) -> None:
"""Append named idle-alpha sleeve engines without touching risk config."""
if not self.idle_alpha_sleeve_preset:
return
preset = IDLE_ALPHA_SLEEVE_PRESETS.get(self.idle_alpha_sleeve_preset)
if preset is None:
raise ValueError(
"Unknown idle alpha sleeve preset: "
f"{self.idle_alpha_sleeve_preset}. Available: {list(IDLE_ALPHA_SLEEVE_PRESETS.keys())}"
)
existing_engine_ids = {engine.engine_id for engine in self.strategy_engines}
appended_engines: list[StrategyEngineConfig] = []
for engine_payload in preset["strategy_engines"]:
engine = StrategyEngineConfig.model_validate(engine_payload)
if engine.engine_id in existing_engine_ids:
continue
appended_engines.append(engine)
existing_engine_ids.add(engine.engine_id)
if appended_engines:
self.strategy_engines.extend(appended_engines)
idle_alpha_payload = preset.get("idle_alpha")
if idle_alpha_payload:
current_idle_alpha = self.idle_alpha.model_dump()
current_idle_alpha.update(idle_alpha_payload)
self.idle_alpha = IdleAlphaConfig.model_validate(current_idle_alpha)
def get_event_profile(self, event_type: str) -> EventTypeProfile | None:
"""Look up event-type-specific profile. Returns None if no override."""
@ -1040,13 +1631,16 @@ class BacktestConfig(BaseModel):
return StrategyEngineConfig.model_validate(merged)
def get_strategy_engines(self) -> list[StrategyEngineConfig]:
"""Enabled strategy engines in manifest order."""
resolved_engines: list[StrategyEngineConfig] = []
for engine in self.strategy_engines:
"""Enabled strategy engines ordered by priority, then manifest order."""
resolved_engines: list[tuple[int, StrategyEngineConfig]] = []
for index, engine in enumerate(self.strategy_engines):
resolved = self.resolve_strategy_engine(engine)
if resolved.enabled:
resolved_engines.append(resolved)
return resolved_engines
resolved_engines.append((index, resolved))
resolved_engines.sort(
key=lambda item: (-int(item[1].selection_priority), item[0]),
)
return [engine for _, engine in resolved_engines]
def get_active_strategy_engines(self) -> list[StrategyEngineConfig]:
"""Enabled engines that participate in the live portfolio."""
@ -1082,7 +1676,7 @@ class ExperimentManifest(BaseModel):
parent: str | None = None
created_at: str | None = None
created_by: str | None = None
status: str = "active" # draft | active | promoted | retired | archived
status: str = "active" # draft | active | promoted | retired
generation: int | None = None
version_family: str | None = None
changelog: str | None = None
@ -1324,6 +1918,18 @@ class MultiCapitalCommonWindowSummary(BaseModel):
capital_summaries: list[CommonWindowSummary] = Field(default_factory=list)
class ResetCommonWindowSummary(BaseModel):
"""Path-neutral common-window summary built from reset-capital segments."""
window_name: str = "reset_common_window"
snapshot_id: str = ""
start_date: dt.date
end_date: dt.date
reset_initial_equity: float = 10_000.0
segment_days: int | None = None
segment_summaries: list[CommonWindowSummary] = Field(default_factory=list)
class ConfigDelta(BaseModel):
"""Records what changed from a baseline experiment."""
@ -1344,6 +1950,7 @@ class JournalEntry(BaseModel):
robustness_matrix_summary: RobustnessMatrixSummary | None = None
out_of_time_robustness_summary: RobustnessMatrixSummary | None = None
common_window_summary: CommonWindowSummary | None = None
reset_common_window_summary: ResetCommonWindowSummary | None = None
multi_capital_common_window_summary: MultiCapitalCommonWindowSummary | None = None
sqs_score: float | None = None
sqs_breakdown: dict[str, float] = Field(default_factory=dict)
@ -1367,6 +1974,8 @@ class JournalEntry(BaseModel):
deployment_breakdown: dict[str, float] = Field(default_factory=dict)
common_window_score: float | None = None
common_window_breakdown: dict[str, float] = Field(default_factory=dict)
reset_common_window_score: float | None = None
reset_common_window_breakdown: dict[str, float] = Field(default_factory=dict)
multi_capital_common_window_score: float | None = None
multi_capital_common_window_breakdown: dict[str, float] = Field(default_factory=dict)
verdict: str = "unknown" # better / worse / neutral / unknown
@ -1394,6 +2003,8 @@ class RegistryEntry(BaseModel):
deployment_score: float | None = None
common_window_score: float | None = None
common_window_summary: CommonWindowSummary | None = None
reset_common_window_score: float | None = None
reset_common_window_summary: ResetCommonWindowSummary | None = None
multi_capital_common_window_score: float | None = None
multi_capital_common_window_summary: MultiCapitalCommonWindowSummary | None = None
walk_forward_summary: WalkForwardSummary | None = None

@ -0,0 +1,179 @@
"""Point-in-time Form 4 cluster helpers for leakage-safe residual cash sleeves."""
from __future__ import annotations
import datetime as dt
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any, Iterable
import pyarrow.parquet as pq
from libs.common.logging import get_logger
logger = get_logger(__name__)
@dataclass(frozen=True)
class Form4ClusterEntry:
symbol: str
filing_date: dt.date
as_of_date: dt.date
total_value: float
owner_count: int
transaction_count: int
event_day_count: int
max_purchase_pct: float
median_purchase_pct: float
weighted_purchase_pct: float
max_lag_days: int | None = None
min_lag_days: int | None = None
has_officer_or_director: bool = False
def _coerce_date(value: Any) -> dt.date | None:
if value is None:
return None
if isinstance(value, dt.datetime):
return value.date()
if isinstance(value, dt.date):
return value
text = str(value).strip()
if not text:
return None
try:
return dt.date.fromisoformat(text[:10])
except ValueError:
return None
def _coerce_float(value: Any) -> float | None:
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _coerce_int(value: Any) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
class PointInTimeForm4Calendar:
"""Latest-known same-day Form 4 cluster events by filing date."""
def __init__(self, entries: Iterable[Form4ClusterEntry]) -> None:
grouped: dict[dt.date, list[Form4ClusterEntry]] = {}
for entry in entries:
grouped.setdefault(entry.filing_date, []).append(entry)
self._entries_by_filing_date = {
filing_date: tuple(sorted(
filing_entries,
key=lambda item: (
item.symbol,
-item.owner_count,
-item.total_value,
-item.weighted_purchase_pct,
),
))
for filing_date, filing_entries in grouped.items()
}
@classmethod
def from_parquet(cls, path: Path) -> PointInTimeForm4Calendar:
table = pq.read_table(str(path))
rows = table.to_pylist()
entries: list[Form4ClusterEntry] = []
for row in rows:
symbol = str(row.get("symbol") or "").strip().upper()
filing_date = _coerce_date(row.get("filing_date"))
as_of_date = _coerce_date(row.get("as_of_date") or row.get("filing_date"))
total_value = _coerce_float(row.get("total_value"))
owner_count = _coerce_int(row.get("owner_count"))
transaction_count = _coerce_int(row.get("transaction_count"))
event_day_count = _coerce_int(row.get("event_day_count"))
max_purchase_pct = _coerce_float(row.get("max_purchase_pct"))
median_purchase_pct = _coerce_float(row.get("median_purchase_pct"))
weighted_purchase_pct = _coerce_float(row.get("weighted_purchase_pct"))
if (
not symbol
or filing_date is None
or as_of_date is None
or total_value is None
or owner_count is None
or transaction_count is None
or event_day_count is None
or max_purchase_pct is None
or median_purchase_pct is None
or weighted_purchase_pct is None
):
continue
entries.append(
Form4ClusterEntry(
symbol=symbol,
filing_date=filing_date,
as_of_date=as_of_date,
total_value=total_value,
owner_count=owner_count,
transaction_count=transaction_count,
event_day_count=event_day_count,
max_purchase_pct=max_purchase_pct,
median_purchase_pct=median_purchase_pct,
weighted_purchase_pct=weighted_purchase_pct,
max_lag_days=_coerce_int(row.get("max_lag_days")),
min_lag_days=_coerce_int(row.get("min_lag_days")),
has_officer_or_director=bool(row.get("has_officer_or_director") or False),
)
)
logger.info(
"pit_form4_calendar_loaded",
path=str(path),
rows=len(entries),
filing_dates=len({entry.filing_date for entry in entries}),
symbols=len({entry.symbol for entry in entries}),
)
return cls(entries)
def get_events_between(
self,
*,
start_filing_date: dt.date,
end_filing_date: dt.date,
symbols: Iterable[str] | None = None,
) -> list[Form4ClusterEntry]:
symbol_filter = {
str(symbol).strip().upper()
for symbol in (symbols or [])
if str(symbol).strip()
}
rows: list[Form4ClusterEntry] = []
current = start_filing_date
while current <= end_filing_date:
for entry in self._entries_by_filing_date.get(current, ()):
if symbol_filter and entry.symbol not in symbol_filter:
continue
rows.append(entry)
current += dt.timedelta(days=1)
return rows
@lru_cache(maxsize=8)
def load_pit_form4_calendar(path_str: str) -> PointInTimeForm4Calendar | None:
path = Path(path_str)
if not path.exists():
logger.info("pit_form4_calendar_missing", path=str(path))
return None
return PointInTimeForm4Calendar.from_parquet(path)
__all__ = [
"Form4ClusterEntry",
"PointInTimeForm4Calendar",
"load_pit_form4_calendar",
]

@ -0,0 +1,94 @@
"""Dividend calendar Oracle service methods."""
from __future__ import annotations
import datetime as dt
from typing import Sequence
from libs.oracle_client.client import OracleClient
from libs.oracle_client.models import (
DividendCalendarEntry,
DividendHistoryResponse,
DividendIngestResponse,
DividendUpcomingResponse,
)
def _date_param(value: dt.date | str | None) -> str | None:
if value is None:
return None
if isinstance(value, dt.date):
return value.isoformat()
return str(value)
class DividendService:
def __init__(self, client: OracleClient) -> None:
self._client = client
async def get_upcoming(
self,
*,
as_of_date: dt.date | str | None = None,
from_ex_date: dt.date | str | None = None,
to_ex_date: dt.date | str | None = None,
symbols: Sequence[str] | None = None,
limit: int = 500,
force_refresh: bool = False,
) -> DividendUpcomingResponse:
params: dict[str, object] = {
"limit": int(limit),
"force_refresh": bool(force_refresh),
}
as_of = _date_param(as_of_date)
from_ex = _date_param(from_ex_date)
to_ex = _date_param(to_ex_date)
if as_of:
params["as_of_date"] = as_of
if from_ex:
params["from_ex_date"] = from_ex
if to_ex:
params["to_ex_date"] = to_ex
if symbols:
params["symbols"] = [str(symbol).strip().upper() for symbol in symbols if str(symbol).strip()]
data = await self._client.get("/api/v1/dividends/upcoming", params=params)
entries = [DividendCalendarEntry.model_validate(item) for item in data.get("dividends", [])]
return DividendUpcomingResponse(
dividends=entries,
total_count=int(data.get("total_count", len(entries))),
metadata=dict(data.get("metadata") or {}),
)
async def get_history(
self,
symbol: str,
*,
limit: int = 1000,
force_refresh: bool = False,
) -> DividendHistoryResponse:
data = await self._client.get(
f"/api/v1/dividends/history/{symbol}",
params={"limit": int(limit), "force_refresh": bool(force_refresh)},
)
entries = [DividendCalendarEntry.model_validate(item) for item in data.get("dividends", [])]
annual_yield_estimate = data.get("annual_yield_estimate")
return DividendHistoryResponse(
symbol=data.get("symbol", symbol),
dividends=entries,
total_count=int(data.get("total_count", len(entries))),
annual_yield_estimate=float(annual_yield_estimate) if annual_yield_estimate is not None else None,
metadata=dict(data.get("metadata") or {}),
)
async def ingest(
self,
symbols: Sequence[str],
*,
force_refresh: bool = False,
) -> DividendIngestResponse:
payload = {
"symbols": [str(symbol).strip().upper() for symbol in symbols if str(symbol).strip()],
"force_refresh": bool(force_refresh),
}
data = await self._client.post("/api/v1/dividends/admin/ingest", json=payload)
return DividendIngestResponse.model_validate(data)

@ -182,6 +182,47 @@ class ShortRatioResponse(BaseModel):
data: list[ShortRatioPoint] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Dividends
# ---------------------------------------------------------------------------
class DividendCalendarEntry(BaseModel):
ticker: str
ex_dividend_date: str
amount: float
declaration_date: str | None = None
record_date: str | None = None
payment_date: str | None = None
currency: str = "USD"
dividend_type: str = "regular"
frequency: str | None = None
as_of_date: str
source: str
class DividendUpcomingResponse(BaseModel):
dividends: list[DividendCalendarEntry] = Field(default_factory=list)
total_count: int = 0
metadata: dict[str, Any] = Field(default_factory=dict)
class DividendHistoryResponse(BaseModel):
symbol: str
dividends: list[DividendCalendarEntry] = Field(default_factory=list)
total_count: int = 0
annual_yield_estimate: float | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class DividendIngestResponse(BaseModel):
symbols_processed: int
total_records_upserted: int
failed_symbols: list[str] = Field(default_factory=list)
status: str
metadata: dict[str, Any] = Field(default_factory=dict)
# ---------------------------------------------------------------------------
# Screener
# ---------------------------------------------------------------------------

@ -127,6 +127,86 @@ async def test_get_short_volume(httpx_mock: HTTPXMock):
assert len(result.data) == 3
@pytest.mark.asyncio
async def test_get_dividend_history(httpx_mock: HTTPXMock):
from libs.oracle_client.client import OracleClient
from libs.oracle_client.dividends import DividendService
httpx_mock.add_response(
json={
"symbol": "AAPL",
"dividends": [
{
"ticker": "AAPL",
"ex_dividend_date": "2025-02-10",
"amount": 0.25,
"declaration_date": None,
"record_date": None,
"payment_date": None,
"currency": "USD",
"dividend_type": "regular",
"frequency": "quarterly",
"as_of_date": "2025-01-11",
"source": "yfinance",
}
],
"total_count": 1,
"annual_yield_estimate": 1.0,
"metadata": {"note": "ok"},
}
)
async with OracleClient("http://oracle:18001") as client:
svc = DividendService(client)
result = await svc.get_history("AAPL")
assert result.symbol == "AAPL"
assert result.total_count == 1
assert result.dividends[0].ticker == "AAPL"
assert result.dividends[0].ex_dividend_date == "2025-02-10"
@pytest.mark.asyncio
async def test_get_upcoming_dividends(httpx_mock: HTTPXMock):
from libs.oracle_client.client import OracleClient
from libs.oracle_client.dividends import DividendService
httpx_mock.add_response(
json={
"dividends": [
{
"ticker": "AAPL",
"ex_dividend_date": "2025-02-10",
"amount": 0.25,
"declaration_date": None,
"record_date": None,
"payment_date": None,
"currency": "USD",
"dividend_type": "regular",
"frequency": "quarterly",
"as_of_date": "2025-01-11",
"source": "yfinance",
}
],
"total_count": 1,
"metadata": {"as_of_date": "2025-02-07"},
}
)
async with OracleClient("http://oracle:18001") as client:
svc = DividendService(client)
result = await svc.get_upcoming(
as_of_date="2025-02-07",
from_ex_date="2025-02-10",
to_ex_date="2025-02-10",
symbols=["AAPL", "MSFT"],
)
assert result.total_count == 1
assert result.dividends[0].ticker == "AAPL"
assert result.metadata["as_of_date"] == "2025-02-07"
@pytest.mark.asyncio
async def test_get_financial_data(httpx_mock: HTTPXMock):
from libs.oracle_client.client import OracleClient

Loading…
Cancel
Save