From 465e248cb4b9653b351c5a71df0c13bde084744a Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Fri, 8 May 2026 23:49:13 -0700 Subject: [PATCH] Wire pct-trailing end-to-end for EarningsRunup; tune v2 risk profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/backtester/run.py | 18 +- .../earnings_runup_poc_v2_tuned.json | 83 ++++++++ libs/backtest/domain.py | 11 + libs/backtest/earnings_runup.py | 28 ++- libs/backtest/execution.py | 58 +++++- tests/unit/backtest/test_earnings_runup.py | 190 ++++++++++++++++++ 6 files changed, 367 insertions(+), 21 deletions(-) create mode 100644 configs/experiments/earnings_runup_poc_v2_tuned.json diff --git a/apps/backtester/run.py b/apps/backtester/run.py index 1ceee71..1145a3e 100644 --- a/apps/backtester/run.py +++ b/apps/backtester/run.py @@ -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: diff --git a/configs/experiments/earnings_runup_poc_v2_tuned.json b/configs/experiments/earnings_runup_poc_v2_tuned.json new file mode 100644 index 0000000..7a96baa --- /dev/null +++ b/configs/experiments/earnings_runup_poc_v2_tuned.json @@ -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 +} diff --git a/libs/backtest/domain.py b/libs/backtest/domain.py index 5e6aeaa..2e43b39 100644 --- a/libs/backtest/domain.py +++ b/libs/backtest/domain.py @@ -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 diff --git a/libs/backtest/earnings_runup.py b/libs/backtest/earnings_runup.py index 62032c0..d400926 100644 --- a/libs/backtest/earnings_runup.py +++ b/libs/backtest/earnings_runup.py @@ -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_" 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, diff --git a/libs/backtest/execution.py b/libs/backtest/execution.py index 565b078..3b1bf93 100644 --- a/libs/backtest/execution.py +++ b/libs/backtest/execution.py @@ -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 diff --git a/tests/unit/backtest/test_earnings_runup.py b/tests/unit/backtest/test_earnings_runup.py index 63f7965..cb980a2 100644 --- a/tests/unit/backtest/test_earnings_runup.py +++ b/tests/unit/backtest/test_earnings_runup.py @@ -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)