# Strategy Specs Three strategies at increasing signal frequency and decreasing signal cleanliness, all live as of 2026-07-19 (swing-dip-v1 and the intraday pair scheduled and running; see CLAUDE.md §1/§9). dan reversed the original "wait for swing-dip's 20 live trades before starting the intraday strategies" gate since it's paper money for a bounded test window and all three could be evaluated in parallel. **A 4th strategy, `qsr`, was added 2026-07-19 and is NOT covered by this doc** — it doesn't reimplement its own entry logic the way the three below do. It consumes the sibling `yahoo-screener` app's QSR strategy signal directly (that app's `src/config/strategies/quality-swing-recycle.json` + `src/scanner/evaluateStrategy.ts` are the actual rules). See CLAUDE.md §10 for the full writeup — built, verified against live data, schedule loaded 2026-07-19. | Strategy | Timeframe | Trade volume / signal quality | Priority | |---|---|---|---| | Overnight swing-dip | Days–weeks | ~10–30 trades/month, clean signals, each outcome clearly attributable | Start here | | VWAP mean-reversion | Minutes–hours | More trades (good for stats), noisier, feed quality matters | Add second | | Opening range breakout | Minutes–hours | ~1 signal/day/ticker, slow to accumulate data | Add last, or skip | Swing-dip is first for a reason beyond signal quality: it needs the least new infrastructure. It runs on the existing daily-bar `SignalSnapshot` and the existing one-shot scan loop — the build is mostly "wire the existing snapshot into a decision + order, add a risk layer." The two intraday strategies both need a new snapshot type and a new persistent polling loop before either can trade at all (see Shared Infrastructure below). Building swing-dip first proves the journal → metrics → learning loop end to end on the cheapest possible path, so the intraday strategies inherit a validated loop instead of being the first thing to test it. --- ## Quick reference **Overnight Swing-Dip** — implemented as `swing-dip-v1`; as-built values below (decisions 2026-07-18) - Timeframe: daily bars, full UNIVERSE + SPY regime (recorded, not gated — see regime note in §1) - Entry: RSI14 < 40, 5–15% below MA50, relVolume ≥ 1.2. MA50-slope filter deliberately left out of v1 — `ma50SlopePct` is on every trade, so the bucket will show whether falling-slope entries underperform before we add the filter. - Exit: TP +5%, stop = entry − 2×ATR14 capped at 8% (1.5×ATR for Tier 3), time-stop 10 calendar days — chosen over the 15–20 sketch for faster capital recycling; holding-time buckets can argue it back up. - Sizing: fixed-fractional — 0.5% of equity risked to the stop (0.25% Tier 3), 10% notional cap (tier map: `TIER3` in `swingDip.ts`) - Order: bracket, limit entry at signal +0.2%, fixed TP/stop — no drift issue - Volume: ~10–30 trades/month **VWAP Mean-Reversion** — implemented as `vwap-mr-v1` (`src/strategy/vwapMeanReversion.ts`), backtest-only; as-built values below (decisions 2026-07-18) - Timeframe: 1-min bars, full UNIVERSE - Entry: price ≤ sessionVWAP − 0.5×dailyATR14 (see simplification note), confirmed by intraday RSI(2) ≤ 20 - Exit: TP = sessionVWAP-at-signal (static, option 1 from the original spec), stop = entry − 1.0×dailyATR14, EOD flatten (forced on the session's last bar) - Order: same "bracket, static TP" plan as originally specced — **not live-wired yet** - **Simplification vs spec**: the spec calls for `k × intradayATR` (a rolling intraday measure); this uses each symbol's DAILY atr14 in dollar terms instead, held fixed across the whole backtest window — building and testing a fresh rolling-intraday-ATR indicator was out of scope for a backtest-only first pass. `stretchAtrMult` was recalibrated live during backtesting from the spec's literal 1.75 (which fired once in 60 days — 1.75× a full day's range essentially never happens within one session) down to 0.5 (267 trades). This is a scale correction (the threshold was reading full-day-ATR units as if they were intraday-ATR units), not evidence-based tuning. - **Backtest result (60-day window, 30 symbols, 2026-07-18)**: 267 trades, 52.1% win rate (Wilson lower 46.1%), expectancy **−0.12%/trade**, PF 0.87. As calibrated, no edge shown — a legitimate first-pass finding, not something to hand-tune further tonight (that crosses into fitting the past; docs/METRICS.md rule #4). Full breakdown: `data/backtest/vwap-mr-v1-2026-07-18.md`. - Volume: many trades/day confirmed in the backtest — the strategy's whole reason for existing (faster learning-loop feedback) holds up at least on trade count, independent of whether this calibration has edge. - **Trailing-stop variant** (`pnpm backtest:vwap-mr-trailing`, backtest-only): 273 trades, 47.3% win, expectancy **−0.14%/trade**, PF 0.84 — essentially flat vs the fixed exit (Δ expectancy −0.02), unlike swing-dip-v1 where trailing showed a clear improvement. Plausible reason: the average hold here is ~0.1 days (minutes), far too short for a trend to develop before the session forces an EOD flatten — trailing needs room to run, and this strategy doesn't give it much. **Opening Range Breakout** — implemented as `orb-v1` (`src/strategy/orb.ts`), backtest-only; as-built values below (decisions 2026-07-18) - Timeframe: 1-min bars, first 15 min (09:30–09:45 ET) opening range, full UNIVERSE - Entry: break above opening-range high, volume ≥ 1.2× the opening range's average bar volume, no entries after 11:00 ET - Exit: stop = range midpoint, target = entry + 2R (2× the entry-to-stop distance — spec left this open, 2R chosen as the easiest to backtest first), EOD flatten - Order: same bracket plan as specced — **not live-wired yet** - **Backtest result (60-day window, 30 symbols, 2026-07-18)**: 496 trades (higher than the spec's "~1/day/ticker" guess — the 11:00 ET cutoff is generous enough to allow more than one attempt per session on some names), 36.9% win rate (Wilson lower 32.8%), expectancy **−0.06%/trade**, PF 0.91. Also no edge shown as calibrated. Full breakdown: `data/backtest/orb-v1-2026-07-18.md`. - Volume: turned out higher than expected, so the "slow to accumulate data" concern that made this lowest-priority may not hold at this cutoff time — worth another look once there's reason to revisit calibration. - **Trailing-stop variant** (`pnpm backtest:orb-trailing`, backtest-only): 546 trades, 36.4% win, expectancy **−0.03%/trade**, PF 0.94 — very slightly better than fixed (Δ expectancy +0.04) but not meaningfully different. Same short-hold explanation as vwap-mr-v1 above likely applies (~0.1 day avg hold). --- ## 1. Overnight Swing-Dip **Thesis.** Buy quality on dips, sell into the bounce. The system's core philosophy (`CLAUDE.md` §6), formalized here. **Timeframe & universe.** Daily bars, full `UNIVERSE` (~30 names) + `REGIME_SYMBOL` (SPY) for regime context. **Entry signal.** All three conditions map directly onto existing `SignalSnapshot` fields — no new indicators needed: - `rsi14 < 40` - `pctFromMa50` between −15% and −5% (5–15% below MA50) - `relVolume` rising — needs a concrete starting threshold since "rising" isn't itself a number; recommend `relVolume ≥ 1.2` as the initial cut (today's volume 20%+ above the 20-day average), tunable by the learning loop like everything else here. **Falling-knife filter.** The universe is pre-vetted quality, but RSI + MA distance alone can't tell a healthy pullback from a broken trend. A floor on `ma50SlopePct` (e.g., not more negative than some small threshold) would keep the strategy out of names whose 50-day trend has actually turned down. **Decision (2026-07-18): not in v1.** Same reasoning as the regime gate below — `ma50SlopePct` is stored on every trade, so the learning loop can add this filter with evidence (falling-slope bucket underperforming) instead of assumption. The −15% MA50 floor already excludes the deepest knives. **Regime gate — don't add it yet.** `spyAboveMa200` is already in the snapshot and easy to gate on, but per `docs/METRICS.md`'s own anti-self-deception rules, the right move is to launch *without* a hard regime gate, track the regime bucket in metrics, and only add the gate once bear-regime trades actually show negative expectancy. Gating upfront on an assumption is exactly the kind of change the metrics engine exists to justify or reject with evidence instead. **Exit.** - Take-profit: +4–6% per the inherited philosophy — start at +5%, tunable. - Stop: ATR-scaled, not fixed-%, per the project's own MAE-diagnosis rule (fixed-% stops on high-`atrPct` names is the documented failure mode) — e.g. `entry − 2 × atr14`. - Time stop: swing trades don't get EOD-flattened, but they do need a max hold before `exitReason: "time_stop"` fires. **Decision (2026-07-18): v1 uses 10 calendar days** — faster capital recycling and quicker learning-loop feedback than the 15–20 sketched here; the holding-time bucket in `docs/METRICS.md` can argue it back up with evidence. **Trailing stop — backtested AND live-wired, OFF by default (built 2026-07-19).** `pnpm backtest:trailing` compares today's fixed exit against a research-only `ExitMode: "trailing"` over the same historical entries. Result: fixed 577 trades/57.4% win/+0.70% expectancy/PF 1.35 vs trailing 616 trades/49.5% win/+1.03% expectancy/PF 1.60 (payoff ratio 1.00→1.63 — fewer, bigger winners). Per-symbol breakdown in `data/backtest/trailing-vs-fixed-*.md` shows it isn't uniform: **AAPL/INTC** get meaningfully better under trailing (trending names), **JPM/SPY** get worse (more mean-reverting). This is still backtest evidence, not journal evidence (docs/METRICS.md rule #4) — turning it on for real trades is dan's call, made per-decision, not something the backtest alone justifies. Three live-design options were considered; **option 2+3 combined is what got built**: 1. **Daily PATCH of the stop leg's price**, run once during `pnpm trade`. Rejected: the backtest above is effectively daily-resolution but each backtest day uses that day's own high to set the trail before checking that same day's low — more responsive than a real once-a-day check can be. Live daily PATCH would underperform the backtested numbers, not match them. 2. **Alpaca's native `trailing_stop` order type** — server-side, continuously adjusting once submitted, zero polling needed after that. Confirmed against Alpaca's docs that it's a **standalone order only**, not a bracket leg. **Built**: `SwingDipParams.useTrailingStop` (default `false`) — when `true`, `evaluateSwingDip` still computes `trailPercent` from the same ATR-based distance as the fixed stop (so "configuring the trailing percentage" means tuning `stopAtrMult`/`tier3StopAtrMult`/`maxStopPct`, the same knobs the fixed stop already uses — deliberately not a separate flat-% parameter, since a flat percentage across the whole universe would reintroduce the exact "fixed-% stops on high-ATR names" failure mode this project already avoided for the fixed stop). `trade.ts` submits `submitEntryOnly` (no bracket) instead of `submitBracketOrder` when a plan has `useTrailingStop: true`, and stores `usesTrailingStop`/`trailPercent` on the `OpenTrade`. **No fixed take-profit** for these trades — matches the backtest's design; the trail is the only exit. 3. **A second, frequent job**: `scripts/attachTrailingStops.ts` (`pnpm attach-trailing-stops[:dry]`), scheduled via a SEPARATE `launchd` job (`~/Library/LaunchAgents/com.alpaca-trader.attach-trailing-stops.plist`, `StartInterval` 300s = every 5 min, not in git) from the daily `pnpm trade` job. Finds entries with `usesTrailingStop && !trailingStopOrderId`, and the instant one shows a fill, submits `submitTrailingStop` (Alpaca's native type, `time_in_force: "gtc"` so it persists across days unlike everything else in this system) and records the resulting order id. Shrinks the unprotected window from "up to ~23h" (naive daily-only check) to "up to 5 min." **Concurrency**: two independent processes now write `data/open-trades.json` (the daily trade cycle and this 5-min job). Both take `journal/openTradesLock.ts`'s advisory file lock before their read-mutate-write span and skip the run (self-healing next cycle) if it's held — see the footgun in CLAUDE.md §8. A lost write here means an orphaned position, the exact failure mode CLAUDE.md already flagged for this file before trailing stops existed. **Turning it on**: flip `useTrailingStop: true` in `SWING_DIP_V1` (`src/strategy/swingDip.ts`) — one parameter, one change, matches the project's own "one change at a time, human-approved" rule. Confirm the fill-watcher `launchd` job is actually loaded first (`launchctl list | grep attach-trailing-stops`) — if it isn't, new trailing-mode entries go out with no path to ever getting protected. **Order mechanics.** The cleanest of the three for bracket orders — TP and stop are both fixed offsets from entry price computed once at signal time, no moving target like VWAP MR's TP-drift problem. Limit entry is the documented preference (`BracketOrderParams` already recommends it); market is acceptable given this universe's liquidity. **Position sizing.** Built as recommended: fixed-fractional (0.5% of equity risked to the stop, position size derived from stop distance, 10% notional cap), with Tier-3 names (the `TIER3` set in `swingDip.ts`: HOOD, SOFI, SMCI, MARA) at half risk and tighter 1.5×ATR stops. See `src/risk/rules.ts`. **Risk layer.** Built: max 5 concurrent positions (open + pending combined) and a daily kill switch that halts new entries at −2% daily P&L (from Alpaca's `equity` vs `last_equity`, so realized + unrealized). The kill switch never exits positions — the server-side bracket legs handle downside. --- ## 2. VWAP Mean-Reversion (intraday) **Thesis.** On a liquid name, price occasionally stretches meaningfully below session VWAP without a change in the underlying trend. Buy the stretch, exit when price reverts to VWAP. Same dip-buy logic as swing-dip, compressed to one session. **Timeframe & universe.** 1-min bars, `feed=iex` (the documented realistic floor for this system). Full `UNIVERSE` is a reasonable start; SPY/QQQ may revert too cleanly/too fast to be worth the churn — flag for review after the first batch of trades rather than excluding upfront. **Entry signal.** Needs a "how far below VWAP is far enough" threshold, volatility-scaled rather than a flat percentage — a stretch that's normal for a high-ATR% name is a real signal on a low-ATR% one. Candidate: enter when `price ≤ sessionVWAP − k × intradayATR`, k around 1.5–2 as a starting point. Add a confirmation filter so this doesn't buy free-fall — either an intraday RSI(2) oversold reading or a check that the stretch isn't accompanied by an accelerating relative-volume spike (capitulation vs. orderly pullback — the same distinction swing-dip's volume-rising filter is making, just intraday-scoped). **Exit.** Target = session VWAP. Stop = below the stretch low, ATR-scaled (same reasoning as swing-dip's stop). Time stop = EOD flatten; no overnight hold. **The bracket-order tension worth flagging now:** bracket orders require a fixed take-profit price at submission, but VWAP is a moving target — it keeps recalculating as the session accumulates volume. Submitting a bracket with the take-profit pinned at VWAP-at-signal-time means the actual exit target is already stale by the time it's hit. Three ways to resolve, in order of how much they preserve "bracket orders only": 1. Use VWAP-at-signal-time as a static TP anyway — simplest, matches the "bracket orders only, never any other way" rule exactly, and for short-duration mean-reversion trades VWAP won't have drifted far. Recommended starting point. 2. Set TP at a small offset past VWAP-at-signal-time (e.g., VWAP + half the stretch distance) so a slightly-stale target still gets filled without needing to babysit the order. 3. Monitor and cancel/replace the TP leg as VWAP updates — most accurate, but means the bracket isn't truly "fire and forget," which cuts against the "exit legs live server-side so risk stays capped if the process dies" rationale for using brackets at all. Start with (1). It's the one that doesn't require new order-management machinery. **Expected trade volume.** This is the strategy's whole reason for going second — many small stretches per day across a 30-name universe should produce meaningfully more closed trades per week than swing-dip's ~10–30/month, which is the point: faster feedback for the learning loop, assuming the PDT check below doesn't cap it first. --- ## 3. Opening Range Breakout (ORB) **Thesis.** The first 15 minutes of trading (09:30–09:45 ET) establishes a range that reflects the day's initial supply/demand. A volume-confirmed break above that range's high is a continuation signal. **Timeframe & universe.** 1-min bars to build the opening range precisely; same `UNIVERSE`. Lowest priority — architecturally similar to VWAP MR (same intraday-loop and snapshot infrastructure) but adds less: ~1 signal/day/ticker means the learning loop gets data an order of magnitude slower. **Entry signal.** Break above the 09:30–09:45 high, with a volume confirmation filter (the breakout bar's volume relative to the average of the opening-range bars — same relative-volume instinct as swing-dip's `relVolume`, just intraday-scoped). No entry after some cutoff (e.g., late-morning) to avoid chasing a breakout that's already run. **Exit.** Stop at the range midpoint, as specified. Target is the open design question: a fixed R-multiple off the entry-to-stop distance, a projection of the range height added to the breakout point, or a trail — worth picking whichever is easiest to backtest first and letting the metrics engine tell you if it's wrong, rather than guessing. Time stop = EOD flatten, consistent with VWAP MR. **Frequency caveat.** At ~1 signal/day/ticker across 30 names, even 100% signal capture is ~30 trades/day theoretical ceiling, and realistically far fewer days actually produce a clean breakout. Getting to the ~20-trade minimum the metrics engine requires before a bucket means anything will take weeks — this is why it's marked skippable. Prove VWAP mean-reversion and the intraday-loop infrastructure first, then decide if ORB is worth the incremental build. --- ## Shared infrastructure the intraday strategies need that swing-dip doesn't **Built 2026-07-19** (`scripts/intradayWatch.ts`, `pnpm intraday-watch` — see CLAUDE.md §9), superseding most of this section — kept below for the reasoning, updated inline with what actually shipped vs what's still a known gap. **A persistent intraday loop.** ✅ Built: `intradayWatch.ts` on its own `launchd` job (`StartInterval` 120s = every 2 min, loaded 2026-07-19 — CLAUDE.md §9), checking `getClock()` and skipping cleanly when the market's closed. **Session-scoped state.** ✅ Built, but simpler than expected: no cache needed. Each poll fetches today's 1-min bars fresh (from UTC midnight through now) and runs them through `groupSessions()` — the same RTH-filtering function the backtest uses — which naturally produces "today's session so far." No persistent per-symbol state to manage or reset. **An intraday SignalSnapshot.** ✅ Fixed 2026-07-26 — sidestepped the union-type question rather than resolving it. `intradayWatch.ts` already fetches 400 days of daily bars per symbol for the dollar-ATR sizing calc; those bars are enough to run every entry through the existing `buildSnapshot()` (same function swing-dip's scan uses) and get real RSI/MAs/regime instead of nulls, at zero extra API cost. `atr14`/`price`/`asOf` stay overridden with the intraday-specific values (the dollar ATR actually used for sizing, the signal price, the entry moment) — only the previously-null fields changed. Caveat: the daily-bar indicators are lagged to yesterday's close, same simplification as vwap-mr-v1's ATR stand-in above. **EOD flatten.** ✅ Built and real: past 15:55 ET, `intradayWatch.ts` cancels any open bracket legs and market-closes the position, journaling `exitReason: "eod_flatten"`. **PDT check.** ✅ Effectively moot: paper equity is $100k, well above the $25k PDT threshold (confirmed 2026-07-18), so the 3-day-trades-per-5-days cap doesn't bind. Not actively monitored in code — if the account ever dropped under $25k this would need real handling, but isn't a blocker today. --- ## Suggested build order 1. ~~Swing-dip: entry rule + tier map + ATR-scaled stop/TP + risk layer (max positions, kill switch).~~ ✅ Done and live-wired (`swing-dip-v1`, 2026-07-18). 2. ~~Confirm PDT status against the real paper account.~~ ✅ Clear — $100k paper equity is well above the $25k PDT threshold, checked 2026-07-18. 3. ~~VWAP mean-reversion and ORB signal logic + backtest.~~ ✅ Done, backtest-only (2026-07-18) — see the Quick Reference results above. Original sequencing (VWAP MR first, ORB only after) was for the LIVE build; both got backtested the same night once the original "wait for swing-dip's 20 trades" gate was reversed (CLAUDE.md §1) — parallel backtesting carries none of the live risk that gate was protecting against. 4. Remaining, undone: intraday loop + session-scoped VWAP/range caching + `IntradaySignalSnapshot` (shared infra both need — decide the union-type question before writing either strategy's live wiring), EOD flatten job, then wiring each into `pnpm trade` (or a new intraday-specific command).