Wire pct-trailing end-to-end for EarningsRunup; tune v2 risk profile

Fix EarningsRunup trailing config that was previously captured in
Candidate.features only. Add activation-gated pct trailing to
update_trailing_stop with entry-relative giveback semantics
("lock in peak − giveback% of entry"). Plumb through
Candidate → ExecutionConfig → simulator. Default None preserves
legacy pct_X behavior — no impact on engines that don't opt in.

Add earnings_runup_poc_v2_tuned config: per_trade_risk_pct 0.65→0.30,
max_positions 30→8, max_positions_per_sector 30→4, macro_vix_max=30,
trailing_warmup_days 7→0 (activation gate replaces warmup).

v2 backtest (4y, 121 trades) vs v1 baseline (119 trades):
  Total return:  +37.27%  →  +254.69%
  Max drawdown:  53.23%   →  23.97%
  SQS:           45.2     →  69.7
  Robustness:    50.1     →  100.0
  Risk score:    23.5     →  35.0
  STOP r-mult:   −0.41    →  +0.65 (trailing-locked winners)

Promotion thresholds met: MDD < 25%, return preserved, SQS > 55.
Recommend integration as PEAD sleeve adjunct (orthogonal entry timing:
pre-print attention runup vs post-print drift) rather than standalone.

7 new pct-trailing tests: activation gate, ratchet, no-ratchet-down,
reversal-stop, legacy compat, end-to-end engine wiring. 28 passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
I Luk Kim 3 months ago
parent 956cc78f1b
commit 465e248cb4

@ -1194,16 +1194,22 @@ class BacktestRunner:
still_open.append(pos)
continue
# Update trailing stop if configured
if self.config.execution.trailing_model:
# Resolve per-engine effective execution config FIRST so trailing
# honors engine_trailing_model overrides (e.g. EarningsRunup pct_3
# with activation gate). Falls back to global trailing_model when
# no engine override is set.
effective_exec = self._build_effective_execution_config(pos.plan.candidate)
# Update trailing stop if configured (honor engine-level override).
if effective_exec.trailing_model:
update_trailing_stop(
pos, bar,
self.config.execution.trailing_model,
warmup_days=self.config.execution.trailing_warmup_days,
effective_exec.trailing_model,
warmup_days=effective_exec.trailing_warmup_days,
pct_activation=effective_exec.trailing_pct_activation,
pct_giveback=effective_exec.trailing_pct_giveback,
)
effective_exec = self._build_effective_execution_config(pos.plan.candidate)
prev_status = pos.status
trade = simulate_exit(pos, bar, effective_exec, date)
if trade is not None:

@ -0,0 +1,83 @@
{
"experiment_name": "earnings_runup_poc_v2_tuned",
"dataset_snapshot_id": "midlarge-liquid-long-v1_bucketfix_full_audit_canonical_ftb_fix_v2",
"description": "EarningsRunup PoC v2 with risk-tuned exits. v1 baseline: +37.27% return, MDD 53.23% (raw) / 44.46% (SQS risk component), SQS 45.2. Changes: (a) wire pct-trailing end-to-end via new trailing_pct_activation/trailing_pct_giveback fields → trailing only arms after +5% peak P&L, then locks in (peak_pnl - 3%) of entry; (b) per_trade_risk_pct 0.65 → 0.30 (halve sizing); (c) max_positions 30 → 8 (concentration cap); (d) macro_vix_max 30 (skip earnings runup setups in macro stress). Goal: cut MDD from ~53% to <25% while preserving the +37% return signal.",
"base_config": "configs/backtest/return_max_long_v1.json",
"overrides": {
"signal": {
"scoring_model": "return_max_long_v13e",
"score_threshold": 0.0,
"max_candidates_per_day": 18,
"a_tier_score_threshold": 0.99
},
"risk": {
"per_trade_risk_pct": 0.30,
"per_trade_risk_pct_a_tier": 0.30,
"max_daily_new_risk_pct": 50,
"max_positions": 8,
"max_positions_per_sector": 4,
"max_position_value_pct": 25,
"max_adv_fraction": 0.3,
"macro_regime_neutral_size_scaler": 1,
"macro_regime_risk_off_size_scaler": 1,
"veto_unknown_direction": false,
"veto_bearish_direction": false,
"macro_regime_risk_off_a_tier_only": false,
"stop_atr_multiplier": 3,
"allow_budget_downsizing": true,
"cash_parking_preset": null,
"fixed_capital_sizing": false
},
"execution": {
"trailing_warmup_days": 0,
"max_holding_days": 7,
"early_failure_no_progress_days": 1,
"early_failure_no_progress_r": 0.0,
"early_failure_no_progress_fraction": 0,
"lookback_entry_enabled": false
},
"event_type_profiles": {
"earnings_runup_preevent": {
"enabled": true,
"direction_filter": "any",
"max_holding_days_override": 7
}
},
"idle_alpha_sleeve_preset": null,
"form4_capture_sleeve_preset": null,
"ownership_capture_sleeve_preset": null,
"risk_off_alpha_sleeve_preset": null,
"dividend_capture_sleeve_preset": null
},
"strategy_engines": [
{
"engine_id": "earnings_runup_preevent_long",
"event_types": ["earnings_runup_preevent"],
"timing_class": "after_close",
"direction": "long_only",
"entry_timing_policy": "next_open",
"engine_risk_budget_pct": 1.0,
"score_threshold_override": 0.0,
"max_holding_days": 7,
"macro_vix_max": 30.0,
"earnings_runup_enabled": true,
"earnings_runup_days_to_earnings_min": 3,
"earnings_runup_days_to_earnings_max": 7,
"earnings_runup_attention_zscore_20d_min": 1.5,
"earnings_runup_dollar_volume_zscore_20d_min": 1.0,
"earnings_runup_min_avg_dollar_volume": 50000000.0,
"earnings_runup_stop_pct": 0.04,
"earnings_runup_target_pct": 0.08,
"earnings_runup_trailing_activate_pct": 0.05,
"earnings_runup_trailing_giveback_pct": 0.03,
"earnings_runup_calendar_buffer_days": 1,
"enabled": true
}
],
"tags": ["earnings_runup", "preevent_drift", "poc", "risk_tuned"],
"version_family": "earnings_runup",
"status": "draft",
"changelog": "v2: wire pct-trailing end-to-end (5% activation / 3% entry-relative giveback); cut per_trade_risk 0.65→0.30; cap max_positions 30→8; add macro_vix_max=30.",
"parent": "earnings_runup_poc_v1",
"performance_summary": null
}

@ -103,6 +103,8 @@ class Candidate(BaseModel):
engine_target_1_fraction: float | None = None
engine_trailing_model: str | None = None
engine_trailing_warmup_days: int | None = None
engine_trailing_pct_activation: float | None = None # pct profit above entry to arm trailing
engine_trailing_pct_giveback: float | None = None # pct of entry to give back from peak
engine_use_reaction_day_low_stop: bool | None = None
engine_early_failure_close_below_entry_and_reaction_close: bool | None = None
engine_early_failure_no_progress_days: int | None = None
@ -1893,6 +1895,15 @@ class ExecutionConfig(BaseModel):
non_a_tier_target_1_fraction: float | None = None
trailing_model: str | None = None
trailing_warmup_days: int = 0 # days after entry before trailing activates
# Activation-gated pct trailing (opt-in). When trailing_model startswith "pct_"
# AND trailing_pct_activation is not None, the trailing stop is only ratcheted
# up after the position's peak P&L exceeds activation_pct (e.g. +5%). The
# ratcheted stop is computed as: stop = peak_price - giveback_pct * entry_price
# (giveback measured in entry-price terms, NOT peak-price terms — matches the
# EarningsRunup-style "lock in profit minus 3%" semantics). Both None preserves
# the legacy unconditional pct_X behavior.
trailing_pct_activation: float | None = None
trailing_pct_giveback: float | None = None
max_holding_days: int = 10
lookback_entry_enabled: bool = False # enter positions for pre-start events still within holding window
lookback_min_remaining_days: int | None = 3 # min holding days remaining for a lookback entry to be allowed

@ -420,15 +420,24 @@ def _build_candidate_from_inputs(
# ``stop_pct * close`` for the default dynamic_scaler == 1.0.
# target_1_r := target_pct / stop_pct → fixed-R target sits at +target_pct.
# target_1_fraction := 1.0 → fully exit at first target.
# Trailing pct exits are NOT mapped (no clean equivalent in the standard
# trailing system); rely on the engine's trailing_warmup_days override and
# capture trailing config in features for diagnostics.
# Trailing pct exits ARE wired end-to-end via engine_trailing_pct_activation /
# engine_trailing_pct_giveback on Candidate → ExecutionConfig overrides →
# update_trailing_stop activation gate. The trailing model name encodes the
# giveback for legacy logging; the engine fields override the actual stop.
synthetic_atr = max(inputs.last_close_price * 0.02, 0.01)
stop_pct = float(engine.earnings_runup_stop_pct)
target_pct = float(engine.earnings_runup_target_pct)
stop_mult = stop_pct / 0.02 if stop_pct > 0 else 2.0
target_r = target_pct / stop_pct if stop_pct > 0 else 2.0
# Pct-trailing wiring: pick a "pct_<int>" model whose name reflects the
# giveback (used as fallback if pct_giveback is somehow None at runtime,
# and shown in trade diagnostics).
trailing_giveback = float(engine.earnings_runup_trailing_giveback_pct)
trailing_activation = float(engine.earnings_runup_trailing_activate_pct)
trailing_giveback_name = max(1, int(round(trailing_giveback * 100)))
pct_trailing_model = f"pct_{trailing_giveback_name}"
# Score is a deterministic function of the two z-scores so it ranks
# candidates without leaking future information.
z_sum = inputs.attention_zscore_20d + inputs.dollar_volume_zscore_20d
@ -485,8 +494,17 @@ def _build_candidate_from_inputs(
# Map pct-based EarningsRunup exits → engine_*-prefixed overrides on the candidate.
engine_target_1_r=target_r,
engine_target_1_fraction=1.0,
engine_trailing_model=engine.trailing_model_override,
engine_trailing_warmup_days=engine.trailing_warmup_days_override,
# Trailing: prefer engine-level override; fall back to pct trailing model
# derived from the engine's giveback config so the activation-gated
# trailing path is used in update_trailing_stop.
engine_trailing_model=engine.trailing_model_override or pct_trailing_model,
engine_trailing_warmup_days=(
engine.trailing_warmup_days_override
if engine.trailing_warmup_days_override is not None
else 0
),
engine_trailing_pct_activation=trailing_activation,
engine_trailing_pct_giveback=trailing_giveback,
engine_stop_atr_multiplier=stop_mult,
engine_next_open_gap_cap_pct=engine.next_open_gap_cap_pct,
engine_use_reaction_day_low_stop=False,

@ -462,6 +462,8 @@ def update_trailing_stop(
bar: dict[str, Any],
trailing_model: str = "bar_low",
warmup_days: int = 0,
pct_activation: float | None = None,
pct_giveback: float | None = None,
) -> None:
"""Ratchet stop towards price for trailing model. Mutates position in place.
@ -475,6 +477,16 @@ def update_trailing_stop(
Args:
warmup_days: Skip trailing until position has been held this many days.
pct_activation: Optional. When set together with ``trailing_model`` of
``pct_*`` form, trailing only arms after the position's unrealized
P&L exceeds ``pct_activation`` (e.g. ``0.05`` = +5%). Until armed,
the stop stays at the static entry stop. None = legacy behavior
(trail unconditionally after warmup).
pct_giveback: Optional. When set, overrides the percentage parsed from
the ``trailing_model`` string and uses entry-price-relative
giveback: ``stop = peak_price - pct_giveback * entry_price``. This
preserves the EarningsRunup "lock in profit minus 3% of entry"
semantics. None = legacy ``peak * (1 - trail_pct)`` behavior.
"""
is_short = position.plan.candidate.trade_direction == "short"
@ -492,6 +504,19 @@ def update_trailing_stop(
if position.days_held < warmup_days:
return
# Activation gate: when pct_activation is set on a pct_* model, only ratchet
# the stop after peak_pnl crosses the activation threshold.
if pct_activation is not None and trailing_model.startswith("pct_"):
entry_price = position.entry_price
if entry_price <= 0:
return
if is_short:
peak_pnl_pct = (entry_price - position.peak_price) / entry_price
else:
peak_pnl_pct = (position.peak_price - entry_price) / entry_price
if peak_pnl_pct < pct_activation:
return
if is_short:
# Short: trail stop DOWN towards price (tighter = lower stop)
if trailing_model == "bar_low" or trailing_model == "bar_high":
@ -499,11 +524,15 @@ def update_trailing_stop(
if bar_high is not None:
position.current_stop = min(position.current_stop, float(bar_high))
elif trailing_model.startswith("pct_"):
try:
trail_pct = float(trailing_model.split("_")[1]) / 100.0
except (IndexError, ValueError):
trail_pct = 0.03
trail_stop = position.peak_price * (1.0 + trail_pct)
if pct_giveback is not None:
# Entry-relative giveback (EarningsRunup semantics)
trail_stop = position.peak_price + pct_giveback * position.entry_price
else:
try:
trail_pct = float(trailing_model.split("_")[1]) / 100.0
except (IndexError, ValueError):
trail_pct = 0.03
trail_stop = position.peak_price * (1.0 + trail_pct)
position.current_stop = min(position.current_stop, trail_stop)
else:
# Long: trail stop UP (original behavior)
@ -513,11 +542,16 @@ def update_trailing_stop(
new_stop = max(position.current_stop, float(bar_low))
position.current_stop = new_stop
elif trailing_model.startswith("pct_"):
try:
trail_pct = float(trailing_model.split("_")[1]) / 100.0
except (IndexError, ValueError):
trail_pct = 0.03
trail_stop = position.peak_price * (1.0 - trail_pct)
if pct_giveback is not None:
# Entry-relative giveback (EarningsRunup semantics):
# stop = peak - giveback*entry → "lock in (peak_pnl - giveback)"
trail_stop = position.peak_price - pct_giveback * position.entry_price
else:
try:
trail_pct = float(trailing_model.split("_")[1]) / 100.0
except (IndexError, ValueError):
trail_pct = 0.03
trail_stop = position.peak_price * (1.0 - trail_pct)
position.current_stop = max(position.current_stop, trail_stop)
@ -694,6 +728,10 @@ def build_effective_execution_config(
execution_updates["trailing_model"] = candidate.engine_trailing_model
if candidate.engine_trailing_warmup_days is not None:
execution_updates["trailing_warmup_days"] = candidate.engine_trailing_warmup_days
if candidate.engine_trailing_pct_activation is not None:
execution_updates["trailing_pct_activation"] = candidate.engine_trailing_pct_activation
if candidate.engine_trailing_pct_giveback is not None:
execution_updates["trailing_pct_giveback"] = candidate.engine_trailing_pct_giveback
if candidate.engine_early_failure_close_below_entry_and_reaction_close is not None:
execution_updates["early_failure_close_below_entry_and_reaction_close"] = (
candidate.engine_early_failure_close_below_entry_and_reaction_close

@ -627,3 +627,193 @@ def test_exit_trailing_giveback_after_activation():
assert trade.exit_reason == ExitReason.STOP
# Confirm exit price is above original entry — i.e. the trailing stop captured profit.
assert trade.exit_price > pos.entry_price
# ---------------------------------------------------------------------------
# Pct-trailing wiring tests — verify update_trailing_stop honors activation
# threshold and entry-relative giveback (EarningsRunup-style trailing).
# ---------------------------------------------------------------------------
def _bar(*, low: float, high: float, open_: float | None = None, close: float | None = None) -> dict[str, Any]:
return {
"date": dt.date(2026, 4, 15),
"open": open_ if open_ is not None else (low + high) / 2,
"high": high,
"low": low,
"close": close if close is not None else (low + high) / 2,
"volume": 1_000_000,
}
def test_pct_trailing_does_not_arm_below_activation_threshold():
"""Position at +3% (activation=5%) → stop unchanged at -4% from entry."""
from libs.backtest.execution import update_trailing_stop
pos = _build_position_for_runup(entry_price=100.0)
# Bar with high=103 (+3% peak), low=101 → not yet at activation (+5%)
update_trailing_stop(
pos, _bar(low=101.0, high=103.0),
trailing_model="pct_3",
warmup_days=0,
pct_activation=0.05,
pct_giveback=0.03,
)
# peak should track high=103
assert pos.peak_price == 103.0
# stop should NOT have moved up — still at -4% = 96.0
assert pos.current_stop == 96.0
def test_pct_trailing_arms_at_activation_threshold():
"""Peak hits +5% → stop raised to +2% (activation 5% - giveback 3% = 2%)."""
from libs.backtest.execution import update_trailing_stop
pos = _build_position_for_runup(entry_price=100.0)
update_trailing_stop(
pos, _bar(low=102.0, high=105.0),
trailing_model="pct_3",
warmup_days=0,
pct_activation=0.05,
pct_giveback=0.03,
)
# peak = 105, stop = peak - giveback*entry = 105 - 0.03*100 = 102.0
assert pos.peak_price == 105.0
assert pos.current_stop == pytest.approx(102.0)
def test_pct_trailing_ratchets_up_with_continued_profit():
"""Peak rises to +7% → stop raised to +4% (peak - 3% of entry)."""
from libs.backtest.execution import update_trailing_stop
pos = _build_position_for_runup(entry_price=100.0)
# First armed at peak=105 → stop=102
update_trailing_stop(
pos, _bar(low=102.0, high=105.0),
trailing_model="pct_3", warmup_days=0,
pct_activation=0.05, pct_giveback=0.03,
)
assert pos.current_stop == pytest.approx(102.0)
# Continued profit: peak now 107
update_trailing_stop(
pos, _bar(low=104.0, high=107.0),
trailing_model="pct_3", warmup_days=0,
pct_activation=0.05, pct_giveback=0.03,
)
assert pos.peak_price == 107.0
assert pos.current_stop == pytest.approx(104.0)
def test_pct_trailing_does_not_ratchet_down_on_pullback():
"""Peak 107 (stop 104) then peak holds at 107 while bar drops to 105 → stop stays at 104."""
from libs.backtest.execution import update_trailing_stop
pos = _build_position_for_runup(entry_price=100.0)
# Establish peak=107, stop=104
update_trailing_stop(
pos, _bar(low=102.0, high=107.0),
trailing_model="pct_3", warmup_days=0,
pct_activation=0.05, pct_giveback=0.03,
)
assert pos.current_stop == pytest.approx(104.0)
assert pos.peak_price == 107.0
# Pullback: bar high=106 (below previous peak), low=105
update_trailing_stop(
pos, _bar(low=105.0, high=106.0),
trailing_model="pct_3", warmup_days=0,
pct_activation=0.05, pct_giveback=0.03,
)
# Peak unchanged; stop must stay (not ratchet down)
assert pos.peak_price == 107.0
assert pos.current_stop == pytest.approx(104.0)
def test_pct_trailing_reversal_triggers_stop_exit_with_profit():
"""Peak +7% → stop +4%; then bar low touches +3% → position exits via STOP at +4%."""
from libs.backtest.domain import ExitReason
from libs.backtest.execution import simulate_exit, update_trailing_stop
pos = _build_position_for_runup(entry_price=100.0)
# Day 1: peak rises to 107, stop ratchets to 104
update_trailing_stop(
pos, _bar(low=102.0, high=107.0),
trailing_model="pct_3", warmup_days=0,
pct_activation=0.05, pct_giveback=0.03,
)
assert pos.current_stop == pytest.approx(104.0)
# Day 2: bar opens at 105.5, drops to 103.5 → trailing stop at 104.0 hit.
pos.days_held = 1
bar2 = _bar(low=103.5, high=105.5, open_=105.5, close=104.0)
# First update peak (no new high)
update_trailing_stop(
pos, bar2,
trailing_model="pct_3", warmup_days=0,
pct_activation=0.05, pct_giveback=0.03,
)
# Stop still at 104 (peak unchanged)
assert pos.current_stop == pytest.approx(104.0)
# Now simulate exit: bar.low=103.5 < stop=104 → STOP exit at 104
trade = simulate_exit(pos, bar2, _exec_config_for_exit_test(), dt.date(2026, 4, 15))
assert trade is not None
assert trade.exit_reason == ExitReason.STOP
# Exit price ~= 104.0 (the trailing stop, less small slippage), above entry → profit captured
assert trade.exit_price == pytest.approx(104.0, rel=0.005)
assert trade.exit_price > pos.entry_price
def test_pct_trailing_legacy_behavior_when_activation_none():
"""Regression: pct_3 with activation=None must trail unconditionally (legacy behavior)."""
from libs.backtest.execution import update_trailing_stop
pos = _build_position_for_runup(entry_price=100.0)
# Even at +1% peak, legacy pct_3 trails to peak * 0.97 = 100*0.97 = 97 (only ratchets if > current_stop)
# Note: position's current_stop starts at 96, and 101*0.97=97.97 > 96, so it should ratchet up.
update_trailing_stop(
pos, _bar(low=100.0, high=101.0),
trailing_model="pct_3", warmup_days=0,
pct_activation=None, pct_giveback=None,
)
assert pos.peak_price == 101.0
# Legacy formula: peak * (1 - 0.03) = 97.97 (ratchets up from 96)
assert pos.current_stop == pytest.approx(97.97)
def test_pct_trailing_via_engine_candidate_in_effective_exec():
"""End-to-end: candidate built from engine carries pct trailing fields into effective exec."""
from libs.backtest.execution import build_effective_execution_config
setup = _build_full_setup()
engine = _make_engine(
earnings_runup_stop_pct=0.04,
earnings_runup_target_pct=0.08,
earnings_runup_trailing_activate_pct=0.05,
earnings_runup_trailing_giveback_pct=0.03,
)
cands = build_earnings_runup_candidates(
decision_date=setup["decision_date"],
next_trading_date=setup["next_trading_date"],
universe_symbols=[setup["symbol"]],
engine=engine,
upcoming_earnings_provider=setup["upcoming_provider"],
attention_provider=setup["attention_provider"],
bar_provider=setup["bar_provider"],
)
assert len(cands) == 1
cand = cands[0]
# Candidate has the new pct trailing fields
assert cand.engine_trailing_model == "pct_3"
assert cand.engine_trailing_pct_activation == pytest.approx(0.05)
assert cand.engine_trailing_pct_giveback == pytest.approx(0.03)
# Build a minimal BacktestConfig and check effective_exec inherits these
from libs.backtest.domain import BacktestConfig, ExecutionConfig
base_cfg = BacktestConfig(
strategy_name="test",
dataset_snapshot_id="test",
execution=ExecutionConfig(max_holding_days=4),
)
eff = build_effective_execution_config(cand, base_cfg)
assert eff.trailing_model == "pct_3"
assert eff.trailing_pct_activation == pytest.approx(0.05)
assert eff.trailing_pct_giveback == pytest.approx(0.03)

Loading…
Cancel
Save