Add daily_budget_reset mode for research backtesting

New mode (risk.daily_budget_reset=True) where cash_available and sizing
equity reset to initial_equity at the start of each day, regardless of
how many open positions or realized P&L exist. Unlike fixed_capital_sizing
(단리, sizing only), this also treats buying power as if no positions are
held — useful for evaluating signal quality independent of capital constraints.

- domain.py: daily_budget_reset field on RiskConfig
- run.py: _daily_budget_reset flag; _sizing_equity / _sleeve_equity_est /
  _build_portfolio_state all honor the new flag
- backtest_sim.py: daily_budget_reset param threaded through
- direct_runner.py: --daily-budget-reset CLI flag
- routers/backtest.py: BacktestRequest field + cmd arg
- client.ts: BacktestParams / BacktestTask types updated
- Backtest.tsx: checkbox in form + DBR badge in task list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 4 months ago
parent 27b8a49d77
commit 189aa58343

@ -211,6 +211,7 @@ class BacktestRunner:
self._equity_curve: list[DailyPortfolioState] = []
self._candidate_map: dict[str, Candidate] = {} # trade_id → candidate
self._fixed_capital_sizing = config.risk.fixed_capital_sizing
self._daily_budget_reset = config.risk.daily_budget_reset
self._primary_candidate_slate_stats: dict[dt.date, dict[str, int]] = {}
# Cash parking (idle cash → SPY/QQQ/SGOV)
@ -315,20 +316,19 @@ class BacktestRunner:
@property
def _sizing_equity(self) -> float:
"""Equity used for position sizing. Returns initial_capital when fixed_capital_sizing is enabled."""
if self._fixed_capital_sizing:
"""Equity used for position sizing. Returns initial_capital when fixed_capital_sizing or daily_budget_reset is enabled."""
if self._fixed_capital_sizing or self._daily_budget_reset:
return self.initial_equity
return self._equity
def _sleeve_equity_est(self, date: dt.date) -> float:
"""Equity estimate for sleeve budget calculations.
When ``fixed_capital_sizing`` is enabled, returns ``initial_equity`` so
that sleeve allocations (parking, form4, ownership, risk-off, idle-alpha)
stay proportional to the starting capital rather than compounding with
portfolio growth.
When ``fixed_capital_sizing`` or ``daily_budget_reset`` is enabled, returns
``initial_equity`` so that sleeve allocations stay proportional to the
starting capital rather than compounding with portfolio growth.
"""
if self._fixed_capital_sizing:
if self._fixed_capital_sizing or self._daily_budget_reset:
return float(self.initial_equity)
market_value = self._compute_positions_market_value(date)
return self._cash + market_value + self._get_parking_value(date)
@ -7396,7 +7396,11 @@ class BacktestRunner:
date=date,
equity=self._equity,
sizing_equity=self._sizing_equity,
cash_available=self._compute_buying_power(self._equity, gross_exposure),
cash_available=(
self._compute_buying_power(float(self.initial_equity), 0.0)
if self._daily_budget_reset
else self._compute_buying_power(self._equity, gross_exposure)
),
gross_exposure=gross_exposure,
net_exposure=net_exposure,
reserved_risk_budget=self._daily_new_risk_used,

@ -42,6 +42,7 @@ def run_backtest_session_sync(
non_core_allocator_v2_mode: str | None = None,
snapshot_id_override: str | None = None,
fixed_capital_sizing: bool = False,
daily_budget_reset: bool = False,
) -> dict[str, Any]:
"""Run a single strategy using BacktestRunner (same as research backtester).
@ -89,6 +90,7 @@ def run_backtest_session_sync(
config.non_core_allocator_v2.enabled = True
config.non_core_allocator_v2.mode = non_core_allocator_v2_mode
config.risk.fixed_capital_sizing = fixed_capital_sizing
config.risk.daily_budget_reset = daily_budget_reset
# Use merged store (train+valid+test) to cover the full date range.
store = _build_merged_snapshot_store(manifest, config, snapshot_dir_override=None)
@ -609,9 +611,26 @@ def load_snapshot_store_for_session(
asyncio.run(_refresh_snapshot(snapshot_id, universe_profile, manual=False))
except Exception as exc:
logger.warning("snapshot_store_refresh_failed", error=str(exc))
# Write the marker so we don't retry the (failing) refresh on
# every engine init throughout the day.
_snap_path_for_marker = _resolve_snapshot_path(snapshot_id)
if _snap_path_for_marker:
try:
(_snap_path_for_marker / ".last_refresh").write_text(
dt.date.today().isoformat()
)
except Exception:
pass
if not _snapshot_has_required_coverage(snapshot_id, dt.date.today()):
logger.warning("snapshot_store_no_coverage_after_refresh_failure")
return None
# Snapshot is stale (missing recent days) but may still contain
# lookback candidates from earlier dates (e.g. an event filed
# 1-2 weeks ago that we haven't entered yet). Use it anyway —
# the engine will fall back to EventDetector for today's fresh
# events; the stale snapshot is better than nothing for lookback.
logger.warning(
"snapshot_store_stale_using_anyway",
snapshot_id=snapshot_id,
)
snapshot_path = _resolve_snapshot_path(snapshot_id)
if snapshot_path is None:

@ -44,6 +44,7 @@ def main():
non_core_allocator_v2_mode = None
snapshot_id_override = None
fixed_capital = False
daily_budget_reset = False
remaining = sys.argv[7:]
i = 0
while i < len(remaining):
@ -71,6 +72,9 @@ def main():
elif remaining[i] == "--fixed-capital":
fixed_capital = True
i += 1
elif remaining[i] == "--daily-budget-reset":
daily_budget_reset = True
i += 1
else:
i += 1
@ -82,7 +86,8 @@ def main():
(f" · risk_off={risk_off_alpha_sleeve_preset}" if risk_off_alpha_sleeve_preset else "") +
(f" · ncav2={non_core_allocator_v2_mode}" if non_core_allocator_v2_mode else "") +
(f" · snapshot={snapshot_id_override}" if snapshot_id_override else "") +
(" · fixed_capital" if fixed_capital else ""))
(" · fixed_capital" if fixed_capital else "") +
(" · daily_budget_reset" if daily_budget_reset else ""))
sys.stdout.flush()
from apps.paper_trader.backtest_sim import run_backtest_session_sync
@ -101,6 +106,7 @@ def main():
non_core_allocator_v2_mode=non_core_allocator_v2_mode,
snapshot_id_override=snapshot_id_override,
fixed_capital_sizing=fixed_capital,
daily_budget_reset=daily_budget_reset,
)
result_file.parent.mkdir(parents=True, exist_ok=True)

@ -396,6 +396,7 @@ class BacktestRequest(BaseModel):
non_core_allocator_v2_mode: str | None = None # shadow|live
snapshot_id: str | None = None # override dataset_snapshot_id (e.g. for OOT periods)
fixed_capital: bool = False # non-compounding: size positions using initial capital
daily_budget_reset: bool = False # research mode: buying power resets to initial_equity each day
class BatchBacktestRequest(BaseModel):
@ -433,6 +434,7 @@ def _make_task(
non_core_allocator_v2_mode: str | None = None,
snapshot_id: str | None = None,
fixed_capital: bool = False,
daily_budget_reset: bool = False,
) -> dict[str, Any]:
return {
"task_id": str(uuid.uuid4()),
@ -450,6 +452,7 @@ def _make_task(
"error": None,
"pid": None,
"fixed_capital": fixed_capital,
"daily_budget_reset": daily_budget_reset,
"mode": mode,
"has_direct_result": False,
"parking": parking,
@ -930,6 +933,7 @@ def _launch_direct_backtest(req: BacktestRequest) -> dict[str, Any]:
non_core_allocator_v2_mode=eff_non_core_allocator_v2_mode,
snapshot_id=req.snapshot_id,
fixed_capital=req.fixed_capital,
daily_budget_reset=req.daily_budget_reset,
)
task_id = task["task_id"]
@ -962,6 +966,8 @@ def _launch_direct_backtest(req: BacktestRequest) -> dict[str, Any]:
cmd += ["--snapshot-id", req.snapshot_id]
if req.fixed_capital:
cmd += ["--fixed-capital"]
if req.daily_budget_reset:
cmd += ["--daily-budget-reset"]
with open(log_file, "wb") as log_fp:
proc = subprocess.Popen(

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-DAmIlbXo.js"></script>
<script type="module" crossorigin src="/assets/index-CuJd8vEh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-AVDbFxMk.css">
</head>
<body>

@ -222,6 +222,7 @@ export interface BacktestRequest {
non_core_allocator_v2_mode?: 'shadow' | 'live' | null;
snapshot_id?: string | null; // override dataset snapshot
fixed_capital?: boolean;
daily_budget_reset?: boolean;
}
export interface BacktestLastParams {
@ -248,6 +249,7 @@ export interface BacktestTask {
mode?: 'cli' | 'direct';
has_direct_result?: boolean;
fixed_capital?: boolean;
daily_budget_reset?: boolean;
parking?: string | null;
idle_alpha?: string | null;
form4_sleeve?: string | null;
@ -336,6 +338,7 @@ export interface IntradayTask {
config: string;
days: number;
universe: string;
universe_label?: string | null;
year: string | null;
start_date: string | null;
end_date: string | null;
@ -410,23 +413,37 @@ export interface IntradayStrategy {
description: string;
days: number;
universe: string;
universe_label?: string | null;
universe_symbols_file?: string | null;
strategy_mode?: 'momentum' | 'orb';
output_dir?: string;
initial_capital: number;
risk_per_trade_pct: number;
max_position_pct: number;
atr_stop_multiplier: number;
min_rvol: number;
max_candidates: number;
daily_max_loss_pct: number;
max_stops_per_day: number;
breakeven_at_r: number;
trailing_at_r: number;
trailing_stop_atr_multiplier?: number;
order_timeout_minutes: number;
settlement_days?: number;
risk_per_trade_pct?: number | null;
max_position_pct?: number | null;
atr_stop_multiplier?: number | null;
min_rvol?: number | null;
max_candidates?: number | null;
daily_max_loss_pct?: number | null;
max_stops_per_day?: number | null;
breakeven_at_r?: number | null;
trailing_at_r?: number | null;
trailing_stop_atr_multiplier?: number | null;
order_timeout_minutes?: number | null;
settlement_days?: number | null;
max_gap_pct?: number | null;
sim_bar_minutes?: number;
orb_minutes?: number;
compound_returns?: boolean;
entry_minutes_after_open?: number | null;
exit_minutes_before_close?: number | null;
top_n?: number | null;
min_morning_gain_pct?: number | null;
max_morning_gain_pct?: number | null;
min_entry_volume?: number | null;
stop_loss_pct?: number | null;
trailing_stop_pct?: number | null;
ticker_cooldown_days?: number | null;
market_regime_spy_threshold?: number | null;
}
export type CreateStrategyPayload = Omit<IntradayStrategy, 'slug'>;
@ -567,6 +584,7 @@ export interface PaperTrade {
exit_price: number;
exit_reason: string;
shares: number;
entry_shares: number | null;
net_pnl: number;
r_multiple: number;
holding_days: number;

@ -98,6 +98,7 @@ interface DupState {
end: string | null;
no_trades: boolean;
fixed_capital: boolean;
daily_budget_reset: boolean;
parking: string | null;
idle_alpha: string | null;
form4_sleeve: string | null;
@ -116,6 +117,7 @@ function makeDupState(task: BacktestTask): DupState {
end: !isYear ? task.end_date ?? null : null,
no_trades: task.no_trades ?? false,
fixed_capital: task.fixed_capital ?? false,
daily_budget_reset: task.daily_budget_reset ?? false,
parking: task.parking ?? null,
idle_alpha: task.idle_alpha ?? null,
form4_sleeve: task.form4_sleeve ?? null,
@ -212,6 +214,7 @@ export function BacktestPage() {
const [capital, setCapital] = useState('10000');
const [noTrades, setNoTrades] = useState(false);
const [fixedCapital, setFixedCapital] = useState(false);
const [dailyBudgetReset, setDailyBudgetReset] = useState(false);
const [directMode, setDirectMode] = useState(true);
const [parking, setParking] = useState('');
const [idleAlpha, setIdleAlpha] = useState('');
@ -244,6 +247,7 @@ export function BacktestPage() {
setNoTrades(s.no_trades ?? false);
dupFixedCapitalRef.current = s.fixed_capital ?? false;
setFixedCapital(s.fixed_capital ?? false);
setDailyBudgetReset(s.daily_budget_reset ?? false);
setParking(s.parking ?? '');
setIdleAlpha(s.idle_alpha ?? '');
setForm4Sleeve(s.form4_sleeve ?? '');
@ -386,6 +390,7 @@ export function BacktestPage() {
: (endDate || null),
no_trades: noTrades,
fixed_capital: fixedCapital,
daily_budget_reset: dailyBudgetReset,
parking: parking || null,
idle_alpha: idleAlpha || null,
form4_sleeve: form4Sleeve || null,
@ -828,6 +833,10 @@ export function BacktestPage() {
<input type="checkbox" checked={fixedCapital} onChange={e => setFixedCapital(e.target.checked)} style={{ width: 13, height: 13 }} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: fixedCapital ? 'var(--orange)' : 'var(--text3)' }}>Fixed Capital (Simple Return)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
<input type="checkbox" checked={dailyBudgetReset} onChange={e => setDailyBudgetReset(e.target.checked)} style={{ width: 13, height: 13 }} />
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: dailyBudgetReset ? 'var(--purple)' : 'var(--text3)' }}>Daily Budget Reset</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: selectedExps.length === 1 ? 'pointer' : 'not-allowed', opacity: selectedExps.length === 1 ? 1 : 0.4 }}>
<input
type="checkbox"
@ -1080,7 +1089,7 @@ export function BacktestPage() {
<td style={{ padding: '11px 14px', fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--cyan)', whiteSpace: 'nowrap' }}>
${task.capital?.toLocaleString() ?? '—'}
</td>
<td style={{ padding: '11px 14px', whiteSpace: 'nowrap' }}>
<td style={{ padding: '11px 14px', whiteSpace: 'nowrap', display: 'flex', gap: 4, alignItems: 'center' }}>
{task.fixed_capital && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
@ -1090,6 +1099,15 @@ export function BacktestPage() {
borderRadius: 3, padding: '1px 5px',
}}>FC</span>
)}
{task.daily_budget_reset && (
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
color: 'var(--purple)',
background: 'color-mix(in srgb, var(--purple) 12%, transparent)',
border: '1px solid color-mix(in srgb, var(--purple) 25%, transparent)',
borderRadius: 3, padding: '1px 5px',
}}>DBR</span>
)}
</td>
<td style={{ padding: '11px 14px', fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text2)' }}>
{task.start_date ? (

@ -803,6 +803,40 @@ PARKING_PRESETS: dict[str, dict] = {
"cash_parking_overlay_shock_brake_sma_buffer": 0.005,
"cash_parking_overlay_shock_brake_rv_ratio_upper": 0.45,
},
# ── Brake v2 + wider TQQQ vol threshold (0.22→0.26): more TQQQ exposure in moderate-vol regimes ──
"qqqm_low_dd_tqqq_active_v2_gld_brake_v2_vol026": {
"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.35,
"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.26, # raised from 0.22 → more TQQQ in moderate vol
"cash_parking_low_vol_overlay_temperature_max": 1.05,
"cash_parking_low_vol_overlay_entropy_max": 1.45,
"cash_parking_defensive_symbol": "gld",
"cash_parking_defensive_relay_enabled": True,
"cash_parking_defensive_relay_trigger_mode": "always",
"cash_parking_defensive_relay_risk_score_max": 100.0,
"cash_parking_defensive_momentum_min": 0.05,
"cash_parking_overlay_shock_brake_enabled": True,
"cash_parking_overlay_shock_brake_rv_ratio": 99.0,
"cash_parking_overlay_shock_brake_dd5_pct": 1.0,
"cash_parking_overlay_shock_brake_sma_cross": False,
"cash_parking_overlay_shock_brake_cooldown_days": 2,
"cash_parking_overlay_shock_brake_sma_buffer": 0.005,
"cash_parking_overlay_shock_brake_rv_ratio_upper": 0.45,
},
# ── Brake v3: wider SMA trigger + longer cooldown for DD headroom ────────────
"qqqm_low_dd_tqqq_active_v2_gld_brake_v3": {
"cash_parking_enabled": True,
@ -1647,6 +1681,7 @@ class RiskConfig(BaseModel):
veto_unknown_direction: bool = True # block if event_direction == "unknown"
veto_bearish_direction: bool = True # block if event_direction == "bearish"
fixed_capital_sizing: bool = False # when True, position sizing uses initial_capital instead of current equity
daily_budget_reset: bool = False # each day, sizing and buying power reset to initial_equity regardless of open positions (for research)
reaction_size_cap_threshold: float | None = None # abs(reaction)% above which size scales down (e.g. 0.08)
momentum_size_scaler_threshold: float | None = None # pre-event mom_20d above which size scales down (e.g. 0.10)
momentum_size_scaler_floor: float = 0.3 # minimum scaler for high-momentum entries

Loading…
Cancel
Save