Admin Reference

End-to-End Trading Workflow

Complete pipeline from hourly scanner → signal review → trade entry → copy execution → position exit. Includes the automated flowcharts, every API endpoint, the hourly schedule, and the BO tracker & backtest archive.

⏱
Timer
systemd · :35 past
each hour (AWS)
1
Scanner
scan_dip.py
→ history + email
1b
BO Tracker
track_bo_entries.py
→ entries + archive
2
Perf Sync
sync_perf.py
→ perf_trades
3
RH Copy
copy_rh_orders
→ detect & mirror
4
Health Check
check_customer_health
→ alert on cred failure
5
Buy
Admin → RH
→ copy to customers
6
Close
RH page or
RH App → copied
7
Report
Friday EOD
P&L emails (admin + customers)
1
Hourly Pipeline — dip_scan.sh
Runs automatically every hour on the AWS server (systemd timer, Mon–Fri) · output goes to the service journal (journalctl -u dip-swing-scan)
Scanner Criteria (scan_dip.py)
  • ✓Price down ≥ 8% intraday
  • ✓Market cap ≥ $2B
  • ✓Avg volume ≥ 500K shares
These three gate the scan. Support-level proximity (±3%) is computed for every candidate and used to sort results — it does not filter them, so a dip with zero support hits still appears in dip_signals.json. The "At support only" checkbox on Live/Past Signals applies that filter client-side (default off).
11 Support Levels Checked
  • MMonthly EMA20 · Monthly EMA50
  • WWeekly EMA20 · Weekly EMA50 · Weekly SMA200
  • DDaily EMA20 · Daily EMA50 · Daily SMA200
  • HHourly EMA20 · Hourly EMA50 · Hourly SMA200
Scanner Output
dip_signals.json — latest scan only (overwritten every scan)

signals_history.json — every signal, kept forever; a day's entry accumulates across all that day's scans, and a still-live signal's price/time refresh to the latest scan

archive/scans/YYYY-MM.jsonl — the raw result of every scan, kept forever (see the BO Tracker & Backtest Archive section)

Note: the tracker below records the actual breakout entries. /api/confirmed still exists (fixed 2026-09-22: it now anchors to the signal's own candle instead of the day's first one, and no longer goes stale by one candle after market close) but Live/Past/Trade Now no longer call it — EMA 20>50 / Price > SMA200 chips replaced the BO columns there.
Schedule (systemd timer · shown in ET)
7:35, 8:35 AMPre-market scansAuto
9:35 AM–3:35 PMHourly scans (market hours)Auto
4:05 PMEOD scan + Friday P&L reportCond.
4:35–7:35 PMPost-market scansAuto
9:33 AM–4:03 PM15-minute BO / Supertrend detector (its own timer)Auto
Mon–Fri only · 14 runs/day · weekends and NYSE holidays are skipped by the scanner · the server clock is Central (timer times are ET − 1h)
Flowchart — Hourly Pipeline (dip_scan.sh)
⏱ systemd timer fires at :35 (Mon–Fri, 7:35 AM–7:35 PM ET incl. 4:05 PM)
① scan_dip.py
TradingView screen · 11 support levels · dip_signals.json + history + raw scan log
New symbols since last scan?
YES
Email notification
via Gmail SMTP
NO
①b track_bo_entries.py
First-seen time · first 1h BO entry · latest BO · Supertrend/ATR snapshot → bo_entries.json + candle archive
② sync_perf.py
Sync RH positions → update perf_trades.json
③ copy_rh_orders.run()
Poll RH last 48h · skip platform & seen orders
New direct RH trades found?
YES
copy_buy() or
copy_close() → customers
NO
④ check_customer_health.py
Ping every active customer's broker account (read-only)
Failing set changed since last run?
YES
Email admin
new failure(s) and/or recovery(ies)
NO — same as last run
Friday & time ≥ 15:05 CDT?
YES
send_perf_report.py
Admin weekly P&L email
send_customer_reports.py
Per-customer weekly email
NO
Done · output goes to the service journal (journalctl -u dip-swing-scan)
1b
BO Tracker & Backtest Archive — track_bo_entries.py
Runs right after every scan · records each signal's breakout entries and keeps the raw data needed to re-run backtests for a year or more
What the tracker records
  • 1First-seen time & price of every signal — the scanner refreshes a live signal's own price/time each scan, so history alone can't say when it first fired
  • 2Entry = close of the first 1h candle after the signal that closes above the previous candle's high (1H BO)
  • 3Forward metrics — max gain, max dip and latest return from the entry, updated for 30 days then frozen
  • 4Latest 1h BO candle — moves forward whenever a newer breakout closes
  • 5Snapshot at entry (frozen) — 15m/1h/D/W/M EMA, SMA, MACD, RSI, breakout, Supertrend, ATR, plus SPY/VIX context
Files kept & retention
signals_history.json — every signal · kept forever
bo_entries.json (data/) — first-seen signals, entries, snapshots · kept forever
archive/bars/15m|1h/SYMBOL.csv — every completed 15m and 1h candle for each signal symbol, plus SPY and ^VIX · kept forever
archive/scans/YYYY-MM.jsonl — raw result of each scan · kept forever
archive/ema_state.json — daily / weekly / monthly EMA 20>50 state, price-below-EMA20 state, RSI 14, Supertrend Buy/Sell and the previous bar's high per signal symbol, refreshed every ~3.5 hours (15m, 1H and 4H are computed from the candle archive; 4H = 9:30–13:30 and 13:30–16:00)
archive/recent_events.json — the signals with a new 15m Supertrend Buy detected, shown on Trade Now · rewritten every 15 minutes
archive/triggers.json — per signal: the first time each trigger fired (early entry, 1H BO, 15m BO, 15m CO, 1H ST, 15m ST) and what buying there returned · rebuilt from the archive every scan
dip_signals.json — overwritten every scan

Yahoo only serves ~60 days of 15m/1h candles, which is why the candles are archived. About 28 MB to start, roughly 60–100 MB per year.
Live-table columns it feeds
15m ST / 1H ST (Supertrend, ATR 10, ×3, hl2) and 15m ST % / 1H ST % come live from GET /api/admin/supertrend (Yahoo, cached 2 min, admin only). ATR % (Live Signals, Past Signals, Trade Now — added 2026-09-22) is the scanner's own daily ATR(14) as a % of price, frozen at the moment the signal was first captured; it's a volatility gauge shown next to the signal, never a scanner filter; has a min/max range filter like Mkt Cap and Avg Vol, plus an "ATR % on:" bucket-chip row above the table (<5%, 5-7%, 7-9%, 9-12%, 12%+ — pick one at a time) for the same field. It only exists for signals from 2026-09-21 onward, so older rows show "—". The Trade Now page lists the 15m Supertrend detections.
Backtesting & backup
Read the archive with src/archive_loader.py — load_bars("SYM","15m"), load_scans("2026-09") and load_triggers() (one row per signal, ready for entry-order and target/stop backtests) — together with signals_history.json and bo_entries.json.

deploy.sh mirrors data/archive to the Mac on every deploy, and a weekly Mac job (launchd com.dipswing.archive.pull, Sundays 10:00, runs on wake if the Mac was asleep) runs scripts/pull_archive.sh: it pulls the archive plus bo_entries.json and signals_history.json and keeps dated copies of those two in data/backups/weekly/ (log: logs/archive_pull.log). push_to_ec2.sh excludes the archive so a deploy can never delete it. The Mac copy is the only off-server copy today.
1c
Backtest Findings — 15m ST entry rule, checked 2026-09-22
Ad-hoc analysis from data/archive/triggers.json, not a scheduled job. Small sample (~3-4 weeks) — re-check with the larger sample when the Dec 2026 re-test happens (see below).
Population & headline result
145 signals with a 15m Supertrend Buy detection at least 1 day old. 113 reached +5% at some point after entry, 32 never did (77.9% hit rate). Buy-and-hold from the 15m ST to now (no target, no stop, simple average): +6.9% per signal — the 109 currently-positive signals average +11.2%, the 36 currently-negative ones average −6.1%.

96.6% of all 145 signals (140 of 145) drew down below entry at some point, regardless of eventual outcome — reaching +5% is not the same as "never went red." All 32 misses drew down; 108 of the 113 hits also drew down first (only 5 — MTSI, CIEN, NVMI, BRZE, INFQ — were clean straight-up moves). 12 of the 113 hits touched +5%+ then reversed to negative (ALMS: +14.9% max gain → −21.9% now) — a take-profit-only exit with no stop would be hurt by these.
What correlated with reaching +5% (checked at the moment of the 15m ST detection)
ConditionHit rateMiss rateGap
Price > Weekly SMA20077.8% (70/90)65.4% (17/26)+12.4 pts
Weekly EMA20>5070.2% (104)63.3% (30)+6.9 pts
Daily EMA20>5046.0% (113)53.1% (32)−7.1 pts
Price > Daily SMA20042.5% (106)46.7% (30)−4.2 pts
15m EMA20>50 at the fliponly 2 of 113 hits had it at all — too rare to judge
RSI 14 (15m/1H/4H/D/W)no gap at any timeframe — nearly identical hit vs miss everywhere
15m MACD histogram >0 & rising94.7%87.5%small, ~universal either way
Rolling 1H BO / 1H ST ever fired98.1% / 98.2% fire regardless of outcome — not a useful filter (why BO/1H-ST-ever chips were removed)
Weekly context (EMA20>50, and especially Price>SMA200) is the only thing that showed a real, if modest, gap. Daily context pointed the wrong way. RSI and MACD showed no edge at any timeframe — both are already bullish almost universally right at the moment a 15m Supertrend flips, so they don't add information on top of the flip itself. This is why the RSI>60 and Price<EMA20 chips were removed the same day (2026-09-22) as the Breakout chips — none of them separated winners from losers in this data.
1d
Backtest Findings — BO15 combo strategy, checked 2026-09-23
Candle-by-candle scan of the archive (not just the known 15m ST trigger events like 1c above) — this is the strategy now live-trading on the AutoTrade page's dedicated $500K paper account.
The rule
Entry: a symbol's 4H Supertrend is already in Buy while its 15m RSI drops below 45 and the 15m Supertrend is still in Sell (a short-term pullback inside a medium-term uptrend) — then buy the moment the 15m Supertrend flips Sell→Buy on a clean breakout (close above the ENTIRE previous 15m candle, wick included). Exit: sell when that symbol's 15m RSI reaches 80.
Population funnel & headline result
Stepn
Symbols with Weekly EMA20>50 AND Price>Weekly SMA20075 of 132
Precondition episodes (4H ST Buy + 15m RSI<45 + 15m ST Sell)2,317
...resolved into an actual 15m ST Sell→Buy flip within ~10 trading hours1,618 (69.8%)
...and that flip was a clean BO (close > prior candle's high)1,448 (89.5% of resolved)
Deduped entries (unfiltered by daily trend)725 — 75.0% hit +5%, avg gain +19.8%, median +12.7%
+ Daily EMA20>50 AND Price>Daily SMA200596 — 85.7% hit, avg +28.7%, median +21.1%
What the trend filter should be — daily, not weekly
FilternHit rateAvg gain
Daily EMA20>50 AND Price>Daily SMA200 = True59685.7%+28.7%
...NOT both True (mixed False/unknown)71967.3%+12.2%
...both False (clean contrast)23860.1%+9.4%
For comparison, the SAME rule split by Weekly EMA/SMA200 instead: 75.0% / 76.4% / 85.9% — flat-to-inverted. Weekly trend adds nothing here (redundant with the 4H condition already in the rule); daily trend is a real, additive +25pt filter.
+ 4H EMA20>50 at entry = True (on top of the daily filter)39276.0%+22.0%
+ 4H EMA20>50 at entry = False33373.9%+17.2%
Signal frequency: ~11 entries/day across the 75 qualifying symbols (range 1–37/day across 64 trading days), so the strategy fires often enough to be worth automating but is bursty, not steady. The AutoTrade account uses the daily-filtered rule (the 596-entry version), sizes each entry at a fixed $10,000, and caps at 50 concurrent positions.
Stop-loss test (2026-09-24) — candle-low stops all hurt the strategy, so AutoTrade uses none
Stop levelMedian distanceStopped outAvg returnWin rate
1H entry candle's low2.0%63.3%+1.20%34.1%
4H entry candle's low3.0%54.5%+1.55%42.4%
Daily entry candle's low3.5%50.5%+1.66%46.5%
No stop (what AutoTrade actually runs — RSI≥80 exit only)——+4.37%75.7%
Every stop level tested made things worse, just by varying degrees — of the trades a stop closed early, 58-66% would have eventually won anyway (avg missed gain +8%). Root cause: this strategy buys pullbacks by design, and 96.6% of ALL entries (win or lose, see 1c above) draw down first — a stop within a few percent of entry sits right inside that normal noise and gets triggered on trades that were simply doing what the strategy expects before working. Decision: no stop-loss at any of the three levels tested; AutoTrade relies on the RSI≥80 exit plus diversification across up to 50 positions instead.
Gap to EMA20 at entry (2026-09-24) — closer/below beats extended, but the edge is thin
ReferenceGroupnHit rateAvg return
Daily EMA20Price below it at entry85 (14.3%)60.0%+7.30%
Price above it at entry511 (85.7%)48.3%+3.88%
4H EMA20Price below it at entry101 (16.9%)56.4%+6.69%
Price above it at entry495 (83.1%)48.7%+3.89%
Correlation is weak overall (Spearman ≈ −0.05 to −0.09), and the Daily version's quintile gradient is fairly smooth (Q1 best at 58.8% hit / +6.55%, declining to Q5's 45.5% / +4.11%) while the 4H version's is noisy/non-monotonic — Daily is the more trustworthy of the two. Makes sense given the strategy's own logic: a bigger gap above the EMA20 means price already ran further before this pullback, leaving less room before the trend tires. Not added as a filter — the best bucket (price below daily EMA20) is only 14.3% of entries, too thin a sample (n=85) to hard-gate on; worth tracking as a data point and revisiting at the Dec 2026 retest with a larger sample.
Touching the 4H Supertrend line after entry (2026-09-24) — a strong descriptive signal that fails as an exit rule
ViewnHit rateAvg return
Descriptive — final outcome (current RSI≥80 exit) split by whether price ever touched the 4H Supertrend's trailing line after entry:
Touched the line at least once (41.3% of entries)24633.3%-0.54%
Never touched it35061.7%+7.81%
As an actual exit rule — simulated selling immediately on first touch instead of waiting for RSI≥80:
Current (RSI≥80 only, no reaction to touches)59650.0%+4.37%
Exit at first touch OR RSI≥80, whichever comes first59636.6%+1.90%
The descriptive gap is the largest found all session — but it's misleading as a trading rule. Of the 246 trades that eventually touched the line, only 50.8% were actually doomed; the other 49.2% would have gone on to win anyway (avg missed gain +9.4%) if held past the touch. Touching correlates with a much longer hold (median 78h vs 19h for non-touchers), so the descriptive split is really comparing "trades that struggled for a long time" against "trades that worked quickly" — true in hindsight, but touching in real time doesn't reliably tell you which half of that 246 you're in. Same lesson as the candle-low stop tests above: almost any "price came back down" signal will look predictive in hindsight for a pullback-buying strategy, without actually being a good real-time trigger. Not added as an exit rule.
1e
Backtest Findings — entry-rule exploration, 2026-09-27 to 10-09
Started as six ad-hoc backtests against the live archive (2026-09-27 to 09-30): (a) re-verify the live strategy's VWAP entry gate, (b) test dropping Supertrend entirely in favor of an RSI-only entry, (c) test a stop-loss defined off the 1H candle, (d) test buying at the raw dip signal instead of waiting for BO confirmation, and (e) test a much stricter multi-timeframe trend filter. Extended 2026-10-05 to 10-09 into a separate line of single-timeframe exploration (Supertrend+EMA entries, stop-loss sweeps, a MACD filter, overlap with BO15). None of this has replaced the live strategy — it's exploration, not a rule change.
VWAP entry gate re-verified against the current archive (2026-09-29)
The live rule (4H ST Buy + clean 15m BO + session VWAP gap ≥1% at that candle, Daily EMA20>50/SMA200 filter — VWAP replaced 15m RSI<45 as the gate on 2026-09-27) re-run against today's archive: 388 candidate setups → 156 trades (53 symbols), 72.4% win rate, 50.6% hit +5%, avg return +3.81%, median +5.10%, profit factor 2.44, expectancy $381/trade, max drawdown −$8,866 (sequential $10,000/trade sizing). Still solid on fresh data — no change made at this point. The retired RSI-wait variant's exact risk-adjusted comparison against this VWAP version remains an open, deferred question (not re-run this session). Superseded the same day — see below.
VWAP gate reversed on a deeper split test — retired from the live strategy (2026-09-29)
VWAP gap bucketnWin rateAvg returnProfit factorExpectancy
< 1% (below the live gate's threshold)3582.9%+4.76%10.40$476
≥ 1% (the gate that had been live since 2026-09-27)14269.7%+3.48%2.20$348
Same clean-BO-in-4H-uptrend population as the re-verification above, but split by the VWAP gap actually measured at each flip candle instead of pre-filtered to ≥1%. Splitting at exactly 0% wasn't usable (only 1 of 177 candidates ever had a negative gap — a clean breakout almost always already sits above the day's VWAP by construction), so the informative cut is at the gate's own 1% threshold. Result is the opposite of the finding that justified adding the gate on 2026-09-27: the below-threshold group now wins on every metric. Two caveats noted at the time — n=35 is a thin sample, and a profit factor of 10.40 is partly a function of few losers rather than necessarily a durable edge; this is also the same VWAP condition being re-tested for a third time this session (2026-09-27 original, today's re-verification, today's split), which is itself the kind of repeated-testing-against-the-same-data risk flagged for the portfolio-sim work. Decision: the VWAP gap ≥1% requirement was dropped from the live strategy — src/recent_events.py's _strategy_match() no longer checks it. Live entry is back to the original 2026-09-23 rule: Daily EMA20>50 + Price>Daily SMA200 + 4H Supertrend Buy + clean 15m BO, no RSI, no VWAP gate. VWAP Gap is still shown on Trade Now and AutoTrade for reference, just no longer required.
Dropping Supertrend entirely — 1H RSI<45 entry, no ST anywhere (2026-09-29)
VariantTradesWin rateAvg returnProfit factorMax DD
Live strategy (ST Buy + clean 15m BO + VWAP, Daily filter)15672.4%+3.81%2.44−$8,866
15m BO (close > prior candle's high) + 4H EMA20>50 + Daily filter, gated on 1H RSI<45 — no Supertrend at all16974.6%+4.31%3.19−$7,938
Same, but Weekly EMA20>50 instead of the Daily filter (no Daily check at all)25666.0%+2.19%1.73−$18,809
Entry: a symbol in a Daily uptrend (EMA20>50, price>SMA200) breaks out on the 15m chart (close above the prior 15m candle's high — no Supertrend flip required anywhere), gated on 1H RSI(14) < 45 at that same moment and 4H EMA20>50 for trend context. 5,339 raw breakout candidates narrow to 169 trades (3.2%) once the RSI gate is applied — strict, but the survivors beat the live strategy on every metric: higher win rate, higher avg return, better profit factor (3.19 vs 2.44), better expectancy ($431 vs $381/trade), smaller drawdown. Swapping the Daily filter for Weekly (dropping the Daily check) roughly doubles the universe (113 vs 53 symbols) and trade count but makes every quality metric worse — the Daily filter is doing real work here, not redundant confirmation. Not yet adopted — promising but tested on the same archive window other variants have already been checked against; needs a genuine forward-test period before being treated as a candidate.
Stop-loss at "breakdown of the 1H RSI candle" — backfires badly (2026-09-29)
Variant (on the 1H RSI<45 entry above, Daily filter)TradesStopped outWin rateAvg returnProfit factorMax DD
No stop (baseline)169—74.6%+4.31%3.19−$7,938
Stop = low of the 1H candle whose RSI gated entry86985.3%22.3%+0.65%1.65−$11,536
85.3% of trades get stopped out before ever reaching the RSI≥80 target exit — the stop sits too close to entry relative to normal 15m noise, especially since this entry specifically requires 1H RSI<45 (weak momentum at entry), where a pullback/retest toward that 1H candle's low before the real move develops is common, not a failure signal. Trade count also jumps 169→869 since faster stop-outs free the symbol up for new entries sooner — same underlying archive, more turnover. Same lesson as the 2026-09-24 candle-low stop tests above: a naive stop on this style of pullback-buying strategy destroys more upside than it protects. Not adopted.
Buying the raw dip signal instead of waiting for BO confirmation (2026-09-29)
Entry stylenWin rateAvg returnMedian returnHit +5%
EARLY — buy at the raw dip/support signal, no confirmation19053.7%+2.20%+1.31%37.9%
BO15 — wait for the confirmed 15m Supertrend breakout (same 190 signals)19051.1%+1.74%+0.46%35.8%
Using the platform's own live signal-tracking data (data/archive/triggers.json, ~4-5 weeks, Aug 28–Sep 25) rather than a fresh archive re-scan: on the SAME 190 signals, buying immediately at the raw dip/support detection edges out waiting for a confirmed 15m BO on every metric, despite having zero filtering of its own. BO15 confirms, on average, +0.47 to +0.59% higher than the original signal price — quantifying the "entries feel late" observation that prompted this test.

Filtering EARLY entries further (fixed 5-trading-day forward return, unbiased by signal recency): Daily EMA20>50 and 4H EMA20>50 both made EARLY entries worse (49.5% / 52.7% win rate vs the 55.4% unfiltered baseline) — consistent with the 2026-09-22 finding above that Daily trend didn't correlate with outcome. Weekly EMA20>50 is the filter that actually helps: 99 filtered signals reached 69.7% win rate / +4.86% avg 5-day return, vs 154 unfiltered at 66.9% / +3.72% — a real, if modest, edge that replicates the original 2026-09-22 Weekly-context finding on an independent, more recent sample. Best candidate from this line of testing: buy at the raw signal, gated only by Weekly EMA20>50 — no Daily filter, no 4H filter, no breakout wait, no Supertrend. Small sample (~100-190 signals, one regime) — needs forward-testing, not yet adopted.
Full Weekly+Daily+4H+1H uptrend stack, pullback entry (2026-09-30)
Universe layer (of 181 archived symbols)Pass count
Weekly EMA20>EMA50>SMA200 alone96
Daily EMA20>EMA50>SMA200 alone61
4H EMA20>EMA50>SMA200 alone30
1H EMA20>EMA50>SMA200 alone37
Weekly + Daily both41
Weekly + Daily + 4H18
Full stack — all four timeframes at once11 — ABCL, AEHR, BE, COHU, ENTG, ERO, HPE, LITE, PBF, SMTC, TGB
Universe filter: EMA20 > EMA50 > SMA200 simultaneously on Weekly, Daily, 4H, and 1H (a full bullish "staircase" on every timeframe at once) — a much stricter conjunction than anything else tested this session, deliberately requiring the whole multi-timeframe trend to agree. Entry on the 11 qualifying symbols: any 15m bar where price is below the 4H EMA20, OR below the 1H EMA50, OR 1H RSI(14) < 45 (any one pullback signal, while the broader stack holds). Result: 78 trades, 65.4% win rate, 34.6% hit +5%, avg return +2.46%, median +1.62%, profit factor 1.99, expectancy $246/trade, max drawdown −$5,024 — the smallest drawdown of any variant tested this session, but the sample is thin and concentrated (LITE, BE, SMTC and TGB alone account for 39 of the 78 trades).

Ceiling check — the same 78 trades re-scored as if each had sold at its own peak high during the hold, instead of waiting for the RSI≥80 exit: win rate 100% (by construction), avg return +6.41%, expectancy $641/trade — roughly 2.6× the actual result, meaning the current exit rule is capturing only ~38% of the move that was actually available on these trades (≈$30,800 left on the table across the 78 trades). Not a tradeable rule on its own (it assumes perfect foresight of the peak) — flagged as the strongest case this session for testing a trailing-stop exit instead of a flat RSI target, since it has the largest actual-vs-ceiling gap of anything backtested.
Everything tested this session, side by side
VariantTradesWin rateAvg returnProfit factorExpectancyMax DD
"Live strategy" as tested here (ST + clean 15m BO + VWAP, Daily filter) — VWAP gate retired the same day, see above; live rule is now ST + clean 15m BO + Daily filter only, no VWAP15672.4%+3.81%2.44$381−$8,866
1H RSI<45, no ST (4H EMA + Daily filter)16974.6%+4.31%3.19$431−$7,938
1H RSI<45, no ST (4H EMA + Weekly filter)25666.0%+2.19%1.73$219−$18,809
↳ + stop at 1H-BO-candle low (Daily filter)86922.3%+0.65%1.65$65−$11,536
Full W/D/4H/1H stack + pullback entry (RSI≥80 exit)7865.4%+2.46%1.99$246−$5,024
↳ same 78 trades, sold at the peak (theoretical ceiling, not tradeable)78100%+6.41%∞$641n/a
By profit factor: 1H RSI<45/no-ST/Daily-filter (3.19) > live strategy (2.44) > full W/D/4H/1H stack (1.99, but smallest drawdown of the real variants) > 1H RSI<45/Weekly-filter (1.73) > 1H-BO-candle stop (1.65, and actively destructive relative to its own no-stop baseline). None of these have replaced the live strategy yet — each carries the same overfitting caveat already established for the portfolio-simulation work (see the project_dipswing_portfolio_sim memory note): candidates need to be frozen and forward-tested on new data, not iterated against the same archive window that produced them.
Live strategy replaced — Daily/4H EMA + RSI<45 + plain 15m BO + near-support, no Supertrend on 15m (2026-09-30)
Adopted directly (not a backtest first) at the user's explicit specification: Daily EMA20>50, 4H EMA20>50 AND 4H Supertrend Buy, 15m RSI<45, a plain 15m breakout (close above the ENTIRE previous 15m candle — no Supertrend condition on 15m at all), and price within 3% of any EMA20/EMA50/SMA200 level on the Weekly, Daily, 4H, 1H or 15m chart (Daily SMA200 is no longer part of the rule — not requested). This is a real architecture change, not just a threshold tweak: every earlier rule (see above and 2026-09-23/24/27/29) required a fresh 15m Supertrend Sell→Buy flip to even generate a candidate — recent_events.py's event stream (and the Trade Now page's own rows) is built entirely around that flip. Since this rule has no 15m Supertrend condition, candidates are now evaluated from each signal's CURRENT state every 15-minute cycle (a new pass over every tracked signal, independent of the flip-event stream) rather than from flip events — see _strategy_match() and the "Autotrade candidates" block in build(). At this point the Trade Now page's rows were still unchanged (still only a fresh 15m Supertrend Buy creates/updates a row) — a symbol could trigger a real autotrade buy without ever appearing there, since the two mechanisms were fully decoupled. Superseded later the same day — the page was coupled to this same match, see below.

Near-support uses the same ±3% tolerance as the original dip scanner. 4H/1H/15m support only checks EMA20/EMA50 (not SMA200) — the local candle archive (~90 days) doesn't cover enough history for a real 200-bar SMA on those timeframes (200 4H bars needs ~100 trading days, 200 1H bars ~31; both computed for Weekly/Daily instead, from the existing 5y daily pull). Verified before deploy: the RSI-appending/near-support math checked against real archive data (VICR matched live — Daily EMA20>50 true, 4H EMA20>50 and ST Buy both true, 15m RSI 43.4, clean 15m BO true, price 0.08%/0.51%/0.51% from 1H EMA20/15m EMA20/15m EMA50 — correctly whipsaw-suppressed as already within 3% of its existing position). No backtest run yet on this exact combination — it replaces the live rule directly rather than being tested first, so treat the coming weeks as the first real read on it. Backtested a few hours later (same day) and found to be the worst-performing entry rule of anything tried this session — see the v2 replacement immediately below.
Live strategy replaced again, same day — v2: Daily EMA20>50/SMA200 + 4H EMA20>50 + 1H RSI<45 + plain 15m BO, no Supertrend anywhere (2026-09-30)
The v1 rule above was backtested after the fact (not before deploy) and came out worst of every variant tried all session: 320 trades, 62.5% win rate, +1.54% avg return, profit factor 1.38, expectancy $154/trade, max drawdown −$37,158 — worse profit factor and roughly 8x the drawdown of the next-worst real variant. A same-session test of the same entry with a +5% profit target added (exit at +5% OR 15m RSI≥80, whichever first) made win rate look better (78.1%) but expectancy WORSE ($117/trade) — capping winners without reducing the loser tail is a net negative in dollar terms, confirmed directly: one real trade (VICR, entered 7/1) hit the RSI≥80 exit while still down −21.84% from entry, since RSI reflects recent relative momentum, not recovery to entry price — the profit cap doesn't touch that risk, it just removes the big wins that used to offset it.

Replaced immediately with the variant that backtested BEST of everything tried this session (see "Dropping Supertrend entirely" above, first row of its comparison table): Daily EMA20>50 AND Price>Daily SMA200, 4H EMA20>50 (no 4H Supertrend condition), 1H RSI<45 (not 15m), and a plain 15m breakout — no Supertrend condition anywhere in the rule. Backtest: 169 trades, 74.6% win rate, +4.31% avg return, median +4.29%, profit factor 3.19, expectancy $431/trade, max drawdown −$7,938 — best profit factor and expectancy of any entry rule tested this session, live strategy included. Typical hold: median 7.9 calendar days (avg 11.9d, range same-day to 50.9d). Trade frequency: ~2.6/trading day averaged over the whole 66-day backtest window, concentrated on 50 of those 66 days (~3.4/active day, median 2.5) — one cold-start-burst day (6/26, 17 entries) skews the max. Every one of the 169 trades was positive at some point during its hold (none stayed underwater the whole way) — peak return averaged +7.73% (median +5.57%) against the +4.31% actually captured, suggesting a trailing-stop exit has more genuine room to help THIS variant than it did the abandoned v1/near-support rule, though a flat +5% target specifically was already shown (above) to hurt a very similar setup rather than help.

Implemented in _strategy_match(sig) using fields already tracked by Series — no new computation needed (unlike v1, which added EMA-value tracking and the near-support check specifically for itself; that code is left in place, unused by the live rule, since it's cheap and still exposed in the signal snapshot for reference). Same candidate-detection architecture as v1: since there's no Supertrend condition, matches are evaluated from each signal's current state every cycle (at this point still independent of the Trade Now page's then-separate 15m-ST-flip-driven rows — see below for when that changed too). Verified against real archive data before deploy (ALAB matched live — Daily EMA20>50 true, Price>SMA200 true, 4H EMA20>50 true, 1H RSI 39.6, clean 15m BO true).
Two more fixes the same day: stale alert copy, repeat alerts for already-held positions, then Trade Now coupled to the v2 rule (2026-09-30)
Two bugs surfaced from real alert emails within hours of the v2 deploy. First: the alert email's description text was still the v1 wording (4H Supertrend Buy required, 15m RSI, near-support) even though _strategy_match() was already v2 — a copy-only bug, confirmed by independently re-checking the actual matched symbols (ALAB/CLS/SANM) against the real v2 conditions directly, all genuinely true. Fixed by updating the hardcoded string in _send_bo15_alert().

Second, more substantive: SANM, CLS and BLSH (all open positions from 9/24-9/25, under earlier rules) kept appearing as "new" signals in consecutive 15-minute emails, with a visibly drifting entry price each time. Root cause: without a Supertrend-flip gate, the v2 rule's conditions can mechanically stay true across many consecutive 15m candles for a symbol whose price has drifted past is_near_last_trade()'s 3% whipsaw window — confirmed directly against the server's real trade log (all three genuinely open, 6-9% away from the prices that kept re-matching). autotrade_entries() already skips buying a symbol it holds, but the alert list was built before that check ran, so it had no way to know. Fixed by adding bo15_autotrade.currently_held_symbols() (reads the local trade log's open positions) and checking it in the candidate loop alongside the whipsaw check — suppresses the alert, not just the trade.

Then, at the user's explicit request ("no more supertrend alert"): the Trade Now page's own row-creation mechanism was replaced too — it previously ran on a completely separate trigger (a fresh 15m Supertrend Sell→Buy flip, via Series.buy_hits()), independent of whatever rule actually drove autotrade. Now the exact same candidates_by_sym match that feeds autotrade also drives the page's rows — one mechanism, not two. Since a state-based rule (no event to anchor to) needs its own notion of "first seen," a small persistent tracker (data/archive/bo15_v2_matches.json) records each symbol's first/last match time and a count, incrementing on each new candle it still matches and starting a fresh episode after a 24h gap since its last match — unit-tested against four scenarios (first match, consecutive match, idempotent re-run of the same candle, fresh episode after a gap) before deploy. The page's rows are intentionally NOT filtered by the whipsaw/already-held suppressions above (same as the old ST-flip version never was) — it's meant to show every genuine rule match, while autotrade/email apply those suppressions separately on top. Series.buy_hits() and the 15m-ST-flip event stream still exist in the code (unused) in case they're useful again later. Superseded hours later the same day — see below.
Trade Now page broadened to EVERY signal, match becomes a badge + opt-in filter, not a row gate (2026-09-30)
The match-gated row design above lasted only a few hours before being reversed, per explicit request: the page should show every signal from Live/Past Signals, like those pages do — not just symbols currently matching the autotrade rule. rows_by_sym now builds one row per symbol from all_sigs unconditionally (same population Live/Past Signals already use), with each row flagged sig["autotrade_match"] = whether it's in candidates_by_sym right now. A match gets a green "New AutoTrade Signal" badge next to its detection time; nothing about which symbols actually get bought changed — candidates_by_sym still drives autotrade_entries() exactly as before.

The persistent bo15_v2_matches.json tracker (first/last/count across runs, added hours earlier) was removed along with it — with every signal now getting a row regardless of matching, "count" simplified to how many distinct days a symbol has appeared as a signal in the lookback window, and "Entry Detected" reverted to meaning when the underlying dip signal was captured (via _parse_captured(), re-imported), not when it last matched the autotrade rule.

Added a new standalone "Show only AutoTrade signals" checkbox below the filter grid — unlike the grid's own EMA/RSI/ST/SMA200/BO/VWAP chips (which filter on raw individual conditions), this one filters on the composite autotrade_match flag directly, i.e. exactly the rows tagged "New AutoTrade Signal." Caught in testing and fixed before deploy: the filter grid's own chip defaults were still pre-checked to replicate the old match-gated rule (Daily+4H EMA20>50, Price>SMA200, 1H RSI<45, 15m BO) from the superseded design above — left in place, a "fresh" page load would have still shown 0 rows outside trading hours despite the backend now returning the full ~178-signal set. Reset every chip row to empty/unchecked by default (localStorage keys bumped so existing browser state doesn't override) so a fresh load genuinely shows everything, with the new toggle as the dedicated way to see just the autotrade matches. Verified twice with Playwright: first pass caught the stale-grid-defaults bug directly (0 rows instead of ~178 on a cleared-localStorage load), second pass confirmed the fix (178 rows, every chip row unchecked, new toggle present and working with no console/page errors in either state).
Full Daily+4H+1H uptrend stack, 15m RSI<40+BO entry — weaker than other variants, funnel diagnosed (2026-10-05)
Funnel stage (of 78,858 total 15m clean-breakout candles)Candles% of all BO% of prior stage
+ Daily EMA20>50>200 also stacked32,97241.8%—
+ 4H stack also true12,55515.9%38.1%
+ 1H stack also true9,88212.5%78.7%
+ 15m RSI<40 also true (final entries)1,1371.4%11.5%
Variant (entry: 15m clean BO + RSI gate; exit: 15m RSI≥80)TradesWin rateAvg returnProfit factor
Daily+4H+1H stack, RSI<40 (as specified)12056.7%+3.20%1.78
Daily+4H+1H stack, RSI<4513558.5%+3.55%1.88
Daily+4H+1H stack, RSI<5014158.9%+3.06%1.73
Daily+1H only (drop 4H), RSI<4014450.7%+0.84%1.15
4H+1H only (drop Daily), RSI<4018065.6%+4.69%2.50
1H only, RSI<4026360.1%+2.53%1.65
Daily only, RSI<4032456.5%+0.98%1.20
Requested test: Daily, 4H and 1H all EMA20>EMA50>EMA200 at once, entry at 15m RSI(14)<40 with a same-candle clean breakout confirmation, exit at 15m RSI≥80, no stop/time limit. Fresh ~2y Daily/1H pulled via yfinance (fixed dates, not a rolling window — the same lesson already learned from the crypto backtest's rolling-window bug) so the 4H/EMA200 math has real history behind it, rather than relying on the ~90-day local 15m archive alone. Result: 136 entries, 120 resolved, 56.7% win rate, +3.20% avg return, profit factor 1.78 — weaker than every BO15/crypto variant validated this session (BO15 live: 74.6%/3.19; crypto: PF 3.7-4.6). Only 35 of 197 archived symbols ever produced a single trade. Excluding the +127.52% MRNA outlier (verified via the raw archive candles — Aug 18 close $62.95 → Aug 19 open $116.25, held $130-175 the following week — a real, persistent gap, not a reverting single-candle glitch, so kept in rather than excluded): 117 resolved, 57.3% win, +2.33% avg, PF 1.58.

Funnel diagnosis (table above) found the 4H leg is the real bottleneck, not the RSI entry — once Daily is stacked, 4H alone kills 62% of what's left (38.1% pass rate) vs 1H's 21%. Testing variants of the filter combination directly: dropping the Daily leg entirely (keeping only 4H+1H) wins on every metric — 180 trades (vs 120), 65.6% win (vs 56.7%), +4.69% avg (vs +3.20%), PF 2.50 (vs 1.78). Daily alone is weak (PF 1.20) and actively hurts when stacked on top of 4H+1H: by the time Daily has been fully stacked for a while the move is already mature, so a 15m RSI<40 dip inside that context is a later-stage pullback rather than a fresh one. Not adopted — a real, free improvement over the originally-specified full-stack rule, but still well short of the live BO15 rule or the crypto variants; documented as a finding, not pushed further.
Single-timeframe entry: Supertrend Buy flip + EMA20>50, RSI≥80 exit — 15m vs 1H, and a censoring-bias trap caught mid-test (2026-10-08/09)
VariantTrades (resolved)Open/unresolvedWin rateAvg returnProfit factorWorst / Best
15m (233 symbols, ~105-day local archive)692164 (19%)70.8%+2.99%2.16-39.0% / +103.8%
1H, same ~105-day archive window (first pass — discarded, see below)115148 (56%)81.7%+10.97%7.80—
1H, re-tested with 2 years of fresh yfinance 1H history1,329161 (11%)74.1%+9.64%3.20-66.9% / +210.7%
Entry: Supertrend (ATR 10, ×3) flips to Buy on a candle AND EMA20>EMA50 at that same candle, single timeframe, no multi-timeframe stack. Exit: RSI(14)≥80, no stop/time limit. Tested against the full 233-symbol tracked universe.

The first 1H pass (PF 7.80) was a trap, caught before reporting it as real: the local 1H archive only spans ~105 days, so a 1H entry needs far fewer calendar days than a 15m one to run out of data before resolving — 56% of 1H entries never got the chance to hit RSI≥80 within the window, and the ones that resolved were disproportionately the fast movers (slow grinds toward 80 just ran out of data and got silently excluded from the "resolved" sample). Re-pulled 2 years of 1H history via yfinance (fixed dates) to remove the censoring: unresolved dropped to 11%, matching 15m's own rate, and the real number is PF 3.20, not 7.80 — still comfortably ahead of 15m's 2.16 on every metric, just not the outlier number the short window implied.

Worst trade verified, not excluded: BULL (Webull Corp), entered 2025-07-22 at $17.00 right at what turned out to be a local top (hit $17.74 the next day), then a real, sustained 8-straight-month monthly decline on normal volume throughout (8-55M shares/day, no single-day crash) to a $4.63 low on 2026-03-30 — down 74%. The RSI≥80 exit didn't fire until 2026-04-14 at $5.82, on the first real bounce off that bottom, locking in -66.85% after a 9-month hold. Confirmed real via the raw daily OHLC, not a data glitch — and the same failure mode as CIFR/LIF in the real manual book: an exit rule that only watches for RSI≥80 has no way to tell a structural breakdown from a healthy pullback.

Trade frequency / holding period (2y window, ~497 trading days, 1H variant): 1,490 total entries → ~3.0/trading day across the universe (3.8/day on the 393 days with ≥1 entry, median 3, busiest day 23). Holding period (resolved only): mean 62.1 days, median 41 days, 25th/75th/90th pct = 15d / 87d / 155d, longest 537.9 days. A long-hold strategy, not a quick-turnaround one.

Train/test split (midpoint 2025-10-09, same 2y window): TRAIN (2024-10-10 to 2025-10-09) — 748 trades, 73.3% win, +8.65% avg, PF 2.83. TEST (2025-10-09 to 2026-10-08) — 676 trades, 74.6% win, +10.51% avg, PF 3.46. The edge replicated and actually strengthened out-of-sample — same pattern as the portfolio-sim's -8% stop validating, unlike its time_15d variant that didn't.
Risk-control attempts on the 1H ST+EMA20>50 variant — a 30-day cleanup and every price stop tested both backfire (2026-10-09)
VariantTradesStopped/cutWin rateAvg returnProfit factor
No stop, no cleanup (baseline)1,329—74.1%+9.64%3.20
+ 30-day stale-cleanup layered on top (the same rule validated for the real manual trade book)2,2261,372 (61.6%)58.8%+2.82%1.57
-5% price stop2,5671,842 (71.7%)27.8%+1.70%1.47
Stop widthTradesWin rateAvg returnProfit factor
-8%1,47036.8%+2.00%1.40
-10%1,46742.1%+2.29%1.40
-15%1,44452.6%+2.86%1.41
-20%1,41960.7%+3.98%1.55
-30%1,38369.3%+6.20%1.90
-40%1,35472.5%+7.59%2.24
-50%1,34073.4%+8.34%2.50
-60%1,33373.9%+9.01%2.82
-70%1,33174.0%+9.47%3.09
-80% (closest to baseline of any width tested)1,33074.1%+9.56%3.15
Two different risk-control ideas tested against the same 1H ST+EMA20>50/RSI≥80 baseline, both net-negative: the 30-day stale-cleanup that helped the real manual trade book (see the bo15_autotrade.py/positions.html change below) actively hurts this strategy — median hold here is 41 days, so a 30-day cutoff force-exits 61.6% of all trades before they can naturally reach RSI≥80, collapsing PF from 3.20 to 1.57. Different strategy shape, opposite answer: the manual book's trades are meant to be fast dip-bounces (median 2 days), so 30 days genuinely meant "this isn't working"; here it means "this hasn't finished yet."

A full stop-loss sweep from -5% to -80% found no width that beats having no stop at all — PF climbs monotonically as the stop widens but never crosses the 3.20 baseline, even at -80% (which almost never fires, since the single worst trade found was -78.7%). The floor is reached almost immediately (~PF 1.40, around -8% to -10%) and doesn't get meaningfully worse from there — the damage from clipping winners mid-drawdown happens fast and doesn't compound further as the stop tightens. The strategy's profit specifically comes from trades that survive a deep drawdown before recovering (BULL above is the extreme case: -74% before the exit finally fired) — any stop, at any width, removes exactly the trades that make it work. Neither idea adopted; risk here would need to come from position sizing or a cap on concurrent positions, not a per-trade exit rule.
Stricter pullback entry (full EMA20>50>200 stack + pullback to EMA20 + RSI~55) — same edge, worse tail risk (2026-10-09)
VariantTradesWin rateAvg returnProfit factorWorstMedian hold
ST flip + EMA20>50 only (simple)1,32974.1%+9.64%3.20-66.9%41.0d
+ full EMA20>50>200 stack, pullback within 1% of EMA20, RSI within ±2 of 551,32472.9%+10.19%3.20 (identical)-78.7%43.8d
Requiring the Supertrend-Buy regime to stay active while EMA20>50>200 fully stacks AND waiting for price to pull back within 1% of EMA20 with RSI landing within ±2 of 55 (abandoning the setup if Supertrend flips back to Sell before that happens) produces a profit factor identical to two decimal places against the simple flip+EMA20>50 entry, on almost the same trade count. The extra conditions enter later into the same moves rather than finding better ones — and entering later into an already-established uptrend is why the worst trade got measurably worse (-78.7% vs -66.9%): closer to the top when a BULL-style breakdown happens. Evidence against over-specifying this entry — more confirmation conditions added complexity and worse tail risk without adding edge.
Adding a MACD filter — the intuitive version hurts, the contrarian version is the best result this round but unvalidated (2026-10-09)
MACD filter (on ST flip + EMA20>50, RSI≥80 exit)TradesWin rateAvg returnProfit factor
No MACD filter (baseline)1,32974.1%+9.64%3.20
MACD above signal (bullish confirmation — the intuitive filter)1,22773.6%+9.15%3.06
MACD above signal AND rising1,22473.5%+9.14%3.05
MACD rising, any sign1,32674.1%+9.63%3.20 (redundant — filters almost nothing)
MACD still BELOW signal — not yet confirmed (contrarian)42176.0%+12.46%4.37
MACD histogram = (EMA12−EMA26) − EMA9-of-that, normalized by close — same convention entry_features.py already uses elsewhere (*_macd_above). Requiring MACD to already confirm bullish at entry (histogram > 0) slightly hurts rather than helps (PF 3.06 vs 3.20) — a lagging indicator, so waiting for it to agree mostly just removes trades without improving the keepers. "MACD rising" alone changes almost nothing (near-total overlap with the baseline condition).

Inverting it — only entering while MACD hasn't confirmed yet (histogram still negative) — jumps profit factor to 4.37, the best number found this round, ahead of BO15's live 3.19 and in the range of the strongest crypto variants. Reading: catching the Supertrend+EMA flip while MACD is still lagging behind means entering earlier, closer to the real inflection, rather than waiting for every indicator to agree (by which point the easy part of the move is often already gone).

Flagged, not adopted — this cuts the sample to a third of baseline (421 trades) and "the contrarian version worked" is exactly the shape of result that needs a train/test check before being trusted, the same discipline already applied to the plain ST+EMA variant above. Not yet re-validated at the time of writing.
Overlap with the live BO15 rule — same stock pool, different entry timing, not a clean new edge (2026-10-09)
Checked whether the 1H ST+EMA20>50 strategy is just rediscovering BO15's own signals under a different name. Restricted to the ~105-day window where local 15m archive data exists for both (2026-06-25 to 2026-10-08), computing BO15's exact live _strategy_match() conditions directly (Daily EMA20>50 + Price>SMA200, 4H EMA20>50, 1H RSI<45, 15m BO — no whipsaw suppression applied, so this counts raw condition-true candles, not real independent trade signals) against the 1H strategy's entries in the same window.

86% symbol overlap (168 of 196 symbols the 1H strategy traded also matched BO15's conditions at some point in the window) — it's largely fishing in the same pond of trending names, not a disjoint universe; only 28 symbols were unique to the 1H strategy. But exact entry timing rarely coincides: only 13 of 493 1H entries (2.6%) landed on the same symbol+day as a BO15 match, and only about half the symbols (103/196) had an entry within ±3 days of one. Read: a different trigger (trend-following breakout vs. BO15's RSI-dip entry) layered on mostly the same underlying stocks — running both live would mean real correlated exposure to the same names, not diversification into new ones.
This round's net read: interesting, not yet implementable
The 1H ST+EMA20>50/RSI≥80 entry (PF 3.20, replicated out-of-sample at 2.83/3.46 train/test) is a genuinely strong result for a single condition on a single timeframe — comparable to BO15's four-condition, three-timeframe rule despite far less machinery, which is itself a point in its favor (less room to have overfit). But three real obstacles surfaced before it could be considered for implementation: (1) no risk-control fix exists yet — every stop width and the already-validated 30-day cleanup both actively hurt it, because its edge specifically depends on surviving deep drawdowns (BULL: -74% before the exit that finally worked); (2) implied capital load is large — ~3 entries/day with a 41-day median hold works out to roughly 120 simultaneous open positions at steady state, with no sizing/concurrency plan; (3) 86% symbol overlap with BO15 means it's largely correlated exposure to names already being traded, not a new, independent edge. None of this has been implemented — documented here as exploration, same status as every other unreplaced variant on this page.
Flowchart — BO Tracker & Archive (runs after each scan)
▶ dip_scan.sh step ①b — right after scan_dip.py
Read signals_history.json
New signal? store its first-seen time + price (never changes after that)
Download Yahoo 1h candles (60 days) for signals from the last 30 days
A 1h candle after the signal closed above the previous candle's high?
YES
Entry = first such candle's close
update latest BO, max gain / dip, return · snapshot indicators once
NO
Listed as “waiting for BO”
Write bo_entries.json (atomic)
Archive candles
append completed 15m + 1h candles per symbol (new symbols backfill 60d, others the last 7d) · merged by timestamp, never duplicated
step ①c build_triggers.py — backtest data generator
for every signal, the first time each trigger fired (early entry · 1H BO · 15m BO · 15m EMA20>50 · 1H ST · 15m ST) + max gain / dip / returns from there · reads only the archive → archive/triggers.json
Done — a failure here never stops the rest of the pipeline; it retries on the next scan
Flowchart — 15-Minute Detector (Trade Now page)
⏱ dip-swing-events.timer — Mon–Fri, 9:33 AM–4:03 PM ET, every 15 min (3 min after each 15m candle closes)
recent_events.py — top up the candle archive
latest 15m + 1h candles for every signal symbol (last 30 days of signals) · shares a file lock with the hourly tracker
For every signal since its first-seen time, find each NEW 15m Supertrend Buy
the 15m Supertrend flips Sell → Buy after the signal — no breakout is required (the page's Breakout chips filter on that); a breakout alone doesn't create or update a row
One row per symbol — the latest detection wins
first detection · latest detection · count — the same entry firing again only updates its row, it never adds a duplicate
Attach each signal's current state
BO / Supertrend Buy / EMA / RSI on all six timeframes · first-BO price · Supertrend last flip
Write archive/recent_events.json → GET /api/admin/recent_events
Trade Now page checks for a new file every minute, live prices every 5 min · rows always newest detection first
Done — a failure keeps the previous file; the next run recomputes everything from the archive
2
Signal Review — Live Signals Page
Admin reads the table, applies filters, and decides which signals to trade
What to look for
  • ✓Multiple support levels hit (more = stronger)
  • ✓Supertrend Buy (15m and 1H) — the short-term trend has turned up
  • ✓ST % columns — how far price has moved since the latest Supertrend flip (avoid chasing a run that already happened)
  • ✓EMA 20>50 / Price > SMA200 chips — bullish trend context on the timeframe(s) you care about
  • !Avoid extended downtrends — check TradingView chart
  • !Check news — avoid earnings, FDA decisions, macro events
Support Stripe (left edge of each row)
  • MGreen — Monthly / Weekly support (strongest)
  • DBlue — Daily support
  • HAmber — Hourly support (weakest)
  • –Gray — no support level hit
ST, EMA/SMA200 & % columns — how they work
15m ST / 1H ST — TradingView Supertrend (ATR 10, ×3, hl2) on 15m and 1h: Buy = uptrend, Sell = downtrend, with time since the last flip. 15m ST % / 1H ST % = % since that flip candle's close

EMA 20>50 / >SMA200 columns — the timeframes (15m, 1H, 4H, D, W, M for EMA; 15m, 1H, D, W for SMA200 — 4H/Monthly need more archived history than exists yet) where that's currently true, from GET /api/admin/supertrend; use the chips above the table to filter on them

No default filter — both pages open showing every signal; “Clear filters” resets any you've added.
Pages involved
Live Signals (admin + client) — current week; admin columns: Date, Time, Symbol, IN $, Now $, Drop %, Now %, 15m ST, 15m ST %, 1H ST, 1H ST %, Mkt Cap, Avg Vol, Sector, with EMA 20>50 / Price > SMA200 filter chips above the table (no default filter — every signal shows until you add one; chip-only, no table column — 2026-09-22)

Past Signals (admin + client) — prior weeks, same columns, chips and no-default-filter behavior on the admin page

Trade Now (admin) — every signal with a new 15m Supertrend Buy detected, newest detection first, refreshed every 15 minutes (only a new 15m Supertrend Buy creates or updates a row); same columns as Live Signals; click the chips above the table (15m, 1H, 4H, D, W, M, plus All / None) to keep only entries whose EMA 20 is above the EMA 50 on the timeframes you pick; a second chip row filters on RSI 14 below 45 per timeframe (combines with the EMA chips); a third chip row keeps only entries whose Supertrend (ATR 10, ×3) is in a Buy on the timeframes you pick; a fourth chip row keeps only entries where the price is above the 200-period SMA (15m, 1H, D, W). Breakout (BO) chips/column were removed 2026-09-22 — confirmation fired on ~98% of signals regardless of outcome, so it wasn't a useful filter; RSI > 60 and Price < EMA20 chips/columns were removed the same day for the same reason (no correlation with outcome in the backtest data); the EMA/RSI/ST/SMA200 table columns themselves (on all three pages) were also dropped the same day — chip-filterable, but no longer shown as columns

Strategy (admin + client) — entry/exit rules reference
3
Trade Entry — Buy on Robinhood Page
Admin clicks Buy → limit order on admin's Robinhood account → copy trade mirrors to all active customers simultaneously
Flowchart — Buy Flow
Admin clicks Buy on Live Signals or Robinhood page
Buy modal — symbol · qty · limit price
POST /api/buy → serve_dip.py
Admin's account
RobinhoodAPI
.buy_limit()
Admin's RH account
perf_trades.json
new trade logged
+
All active customers
copy_trade.copy_buy()
qty = floor(capital ÷ max_trades ÷ price)
Alpaca creds?
→ AlpacaAPI
.limit_buy()
RH token?
→ RobinhoodAPI
.buy_limit()
copy_log.json
per-broker order_id + state logged
Sizing Formula
# Per customer, per trade qty = floor( broker_live_equity / customer.max_trades / limit_price ) # e.g. $50K ÷ 10 ÷ $25.00 = 200 shares
Order Types
Admin (RH): Limit order · time_in_force: day
Customer (Alpaca): Limit order · same price
Customer (RH): Limit order · time_in_force: gfd

Extended hours (pre/after-market): limit orders only — extended_hours flag set automatically.
Skipped if…
  • ✕Customer status ≠ "active"
  • ✕Computed qty < 1 share
  • ✕No broker credentials set
  • ✕Customer capital < $1,000
4
Automatic RH Order Detection — copy_rh_orders.py
Detects trades made directly in Robinhood (bypassing the platform) and copies them to all active customers
Flowchart — Copy RH Orders (runs inside sync_perf.py each hour)
Triggered hourly (via sync_perf.py) or manually via GET /api/copy_rh_orders
RobinhoodAPI.get_orders() — last 48 hours, newest-first
Order older than 48h?
YES
Stop paging
NO — continue
state == "filled"?
NO
Skip
YES
order_id in platform orders or seen set?
YES
Skip + mark seen
NO — new direct trade
side = buy
copy_trade.copy_buy()
→ all active customers
side = sell
copy_trade.copy_close()
→ all active customers
Save seen IDs → data/rh_copy_seen.json (capped at 5,000)
Deduplication — how platform orders are excluded
Platform-originated orders have their order_id stored in perf_trades.json at buy time. copy_rh_orders loads that set and skips any matching order ID, so platform trades are never double-copied. A separate rh_copy_seen.json prevents re-processing across hourly runs.
5
Position Monitoring
Robinhood page shows admin's live RH positions. Alpaca page is a read-only monitor of the first/test client account — used to verify copy trades are landing correctly.
Robinhood Page — Open Positions Panel
Source: GET /api/rh/positions — admin's live Robinhood positions

Shows: Symbol · Qty · Avg Entry · Current Price · Unrealized P&L · Move %

Close button → POST /api/close (RH market sell + copy_close to all customers).
Alpaca Page — First Client Monitor
Source: GET /api/alpaca/positions — first/test client's Alpaca account

Read-only — no Close button. This account is always the same permanent test client. Its positions verify that copy trades are landing. Admin never trades through Alpaca; closing on the RH page (or RH app) will copy-close this account automatically.
Performance Pages (Admin + Client)
Source: perf_trades.json (file, updated hourly by sync_perf.py)

Open positions panel shows live current price via GET /api/prices, updated every 5 minutes during market hours. Shows unrealized P&L per symbol.
Position Signals to Watch
  • !1HR price closes below hourly EMA20 after entry
  • !Target hit (strategy-defined % gain)
  • !Adverse news / fundamental change
  • !Position red past support — no stop-loss; evaluate adding at the next reversal level to improve the average entry
6
Trade Exit — Robinhood Page (or Robinhood App)
Admin closes on the Robinhood page (or directly in the Robinhood app). Copy close mirrors the sell to all customers — including their Alpaca accounts. The Alpaca page is read-only monitoring only.
Close Flow — Robinhood Page (or Robinhood App via copy_rh_orders)
Admin clicks Close on Robinhood page
Modal: symbol · total qty · avg entry · live P&L
POST /api/close {symbol, qty}
RobinhoodAPI.sell_market()
Admin's Robinhood account
Update perf_trades.json
Mark open lots closed · fill price · P&L
copy_trade.copy_close()
Market sell on each customer's broker (Alpaca + RH)
Copy log updated
Customer close logic (copy_trade.copy_close)
# For each active customer: if alpaca_key + alpaca_secret: qty = GET /v2/positions/{symbol} → qty if qty > 0: AlpacaAPI.sell_market(qty) if rh_token: qty = RobinhoodAPI.get_positions() → qty for symbol if qty > 0: RobinhoodAPI.sell_market(qty) # Customers with no position are silently skipped
API
API Endpoints — serve_dip.py (port 8081)
All endpoints at http://localhost:8081 · static files served from website-dip/
MethodPathDescriptionUsed by
GET /api/prices?symbols=… Live last prices via yfinance for comma-separated symbols, including pre/post-market Live Signals, Past Signals, Performance pages
GET /api/confirmed?symbols=… 1H BO status — last complete 1h candle closed above the prior high; tf=15m for 15m BO; with dates=… each signal is checked from its own date forward (Past Signals) Live Signals, Past Signals pages
GET /api/account Admin Robinhood account — equity, buying power, margin, day P&L Robinhood page account strip
GET /api/alpaca/account First/test client's Alpaca account — equity, cash, buying power, margin Alpaca page account strip
GET /api/alpaca/positions First/test client's Alpaca positions — used to verify copy trades are landing Alpaca page (read-only monitor)
GET /api/rh/positions Admin's Robinhood positions with live quotes Robinhood page open positions panel
GET /api/refresh Triggers sync_perf.py — syncs RH positions → perf_trades.json Robinhood page Sync button
GET /api/customers Load all customers from data/customers.json Customers page
GET /api/copy_log Full copy trade log — all buy/close mirror records Customers page copy log
GET /api/copy_rh_orders Manually trigger copy_rh_orders.run() — detect & mirror direct RH trades Robinhood page "Copy RH Trades" button
GET /api/customer_account?id=… Load a single customer's broker account data (equity, positions) Customers page per-customer panel
GET /api/client/resolve?token=… Resolve a client's personal access token to {customer_id, name, joined} — no broker data Client portal login (auth.js requireClient)
GET /api/client/activity?id=… That one customer's copy_log entries only — never the full multi-customer log client/account.html Recent Activity
POST /api/buy Limit buy on admin's Robinhood + copy to all active customers Buy modal (Live Signals / Robinhood page)
POST /api/close Market sell on admin's Robinhood + copy_close to all customers · updates perf_trades.json Close button on Robinhood page positions panel
POST /api/onboard Create new customer record from onboarding form landing.html onboarding flow
POST /api/customers Update existing customer — status, credentials, leverage, max trades, account label (capital is never set by hand: it is always the broker account's live equity) Customers page edit modal
POST /api/admin/login Verify admin password → sets signed, HttpOnly session cookie login.html
POST /api/logout Clears the session cookie Admin nav logout
GET /api/admin/bo_entries Tracked signals: first 1h BO entries, latest BO, max gain / dip, ATR, first-seen times and signals still waiting for a BO (admin only; indicator snapshots are left out) Live & Past Signals (first-seen times for the BO % columns)
GET /api/admin/recent_events The 15-minute detector's output: one row per symbol (its latest detection wins) with a new 15m Supertrend Buy detected, with first / latest detection time and price, count, and the signal's BO / Supertrend state (admin only) Trade Now
GET /api/admin/supertrend?symbols=…&since=SYM@epoch,… Live 15m + 1h Supertrend state, flip time and flip-candle close per symbol, plus the first 15m / 1h breakout candle after each signal's first-seen time (from the archive + fresh candles) · Yahoo, cached 2 min, max 150 symbols (admin only) Live Signals, Past Signals
All admin-only routes above (buy, close, refresh, customers, copy_log, copy_rh_orders, account/positions and /api/admin/* endpoints) require a valid admin session cookie — see the new Deployment Workflow section below for how auth fits into the deployed setup.
∑
System Architecture
How all components connect — automated flows and manual flows
Automated — systemd timer on AWS (Mon–Fri, 7:35 AM–7:35 PM ET)
timer :35
→
dip_scan.sh
→
scan_dip.py
→
dip_signals.json
+
signals_history.json
→
Email (new signals)
events timer :03/:18/:33/:48
→
recent_events.py
→
archive/recent_events.json
→
Trade Now page
every 15 min · also tops up the candle archive
↓
track_bo_entries.py
→
bo_entries.json
+
archive/bars (15m + 1h candles)
+
archive/scans (raw scan log)
scan_dip.py also appends every raw scan to archive/scans
↓
sync_perf.py
→
perf_trades.json
→
copy_rh_orders.run()
→
copy_buy / copy_close → customers
↓
check_customer_health.py
→
customer_health_state.json
diffed vs last run
→
Email admin (only on change)
↓
Friday ≥ 15:05
→
send_perf_report.py → admin weekly P&L email
+
send_customer_reports.py → per-customer weekly email
All output → service journal (journalctl -u dip-swing-scan)
Manual — Admin UI → serve_dip.py :8081
Admin (Robinhood page)
→ POST /api/buy →
RobinhoodAPI → Admin RH account
+
copy_trade → customers
Admin (Robinhood page)
→ POST /api/close →
RobinhoodAPI.sell_market
+
copy_close → customers
Alpaca page — first/test client monitor
read-only · verifies copy trades landed · same account always · never admin's trade broker
Customer Broker Routing (copy_trade.py)
data/customers.json
→ active customers →
Alpaca creds → AlpacaAPI (live or paper)
+
RH token → RobinhoodAPI
(legacy / dual-broker)
data/copy_log.json
← per-broker order_id + state, viewed on Customers page
Client Portal (website-dip/client/) — auth: personal access link, one per customer
?t=<access_token>
→
GET /api/client/resolve
→
signed session cookie + sessionStorage (id/name/joined for the UI)
no shared password — each customer's link is generated from the Customers page (Copy Link). The cookie is what the server actually trusts; sessionStorage is just a UI convenience.
client/signals.html
current week signals · live prices + H Breakout
client/history.html
prior weeks · same filters as admin past_signals.html
client/performance.html
perf_trades.json, filtered to trades entered on/after this client's join date · open positions + live unrealized P&L · weekly table
client/account.html
the client's real broker account, not the strategy log — GET /api/customer_account + GET /api/client/activity (that client's copy_log entries only, including failures)
client/strategy.html
entry/exit rules · support levels · H Breakout checklist
D
Deployment Workflow — Local → Repo → Production
Local dev on the Mac (launchd) mirrors production on AWS (systemd). Three steps ship a change: edit & test locally, commit to GitHub, then run one script to deploy.
Flowchart — Shipping a Change
① Local — edit code, test against local serve_dip.py (localhost:8081)
② Repo — git commit && git push (origin/main on GitHub)
③ Deploy — bash deploy/deploy.sh
Pull prod's live data down first
customers.json, copy_log.json, rh_copy_seen.json, dip_signals.json, signals_history.json, perf_trades.json, bo_entries.json, plus the backtest archive (mirrored to data/archive) — backed up locally
rsync local tree → /opt/dip-swing
push_to_ec2.sh — bypasses git; scan/trade files are excluded outright, never pushed back
systemctl restart dip-swing.service
only needed for Python code changes
Live at https://dip-swing.com
Local vs Production Scheduler
Local (Mac): launchd — only com.dipswing.dip_server (the dev web server) runs. The hourly scan job (com.dipswing.dip.hourly) is disabled — its plist was moved out of ~/Library/LaunchAgents after it was found running redundantly alongside production and clobbering live scan data on every deploy.

Production (AWS EC2): systemd — dip-swing.service (the web app, always running) + dip-swing-scan.timer (hourly pipeline) — the only scheduler for scanning and trading now.
Customer data — bidirectional
Real customers sign up directly on the live site, so customers.json (+ copy_log.json / rh_copy_seen.json) on production is often ahead of the local copy. deploy/deploy.sh pulls these down first — timestamped local backup — then pushes the local tree back up, so either side can be the one that added a record and neither push order can silently erase it.
Scan/trade data — pull-only, never pushed
dip_signals.json, signals_history.json, perf_trades.json, bo_entries.json and the data/archive/ backtest archive are pulled down (the archive is mirrored incrementally) but push_to_ec2.sh excludes them outright — production's own scan is the sole source of truth for these, full stop. A local machine can never overwrite them, no matter what's sitting in the working tree.
What needs a restart
  • ✕Python changes (serve_dip.py, copy_trade.py, etc.) → restart required
  • ✓Static files (HTML/CSS/JS in website-dip/) → live immediately, no restart
  • ✓Scan/report scripts → picked up fresh on the next scheduled run, no restart
Auth — production only
Admin pages and admin-only API routes require a signed, HttpOnly session cookie (POST /api/admin/login). The client portal uses a per-customer access token instead of a shared password — see the Client Portal row in System Architecture above.
C
Customer Schema — data/customers.json
Fields required for copy trade execution
{ "id": "cust_001", "name": "John Doe", "email": "john@example.com", // also the address for weekly reports (send_customer_reports.py) "phone": "5551234567", "state": "GA", "account_label": "", // optional — set when the same email registers a 2nd account on the same broker; onboarding offers Traditional IRA/Rollover IRA/Other (trimmed from a longer list once we confirmed Alpaca doesn't offer self-serve Individual retail signups for Roth/SEP IRA, Joint or Trust), admin can retype any value; part of the onboarding dedup key alongside email+broker "status": "active", // active | pending | paused — filter used: status == "active" "capital": 50000, // signup snapshot only — sizing and every page use the broker's live equity "max_trades": 10, // max simultaneous positions "notes": "", "access_token": "qeQdbXug...", // client portal login — /client/signals.html?t=<this>, no password // Alpaca credentials (new customers) "alpaca_key": "PKXXXXXXXX", "alpaca_secret": "xxxxxxxxxxxxxxxx", "alpaca_paper": true, // true = paper-api.alpaca.markets // Robinhood credentials (legacy / dual-broker) "rh_token": "Bearer xxxxxxxx" }
Broker Routing Rules
  • AHas alpaca_key + alpaca_secret → trades on Alpaca (live or paper)
  • RHas rh_token → trades on Robinhood
  • BHas both → trades on both simultaneously
  • ✕Has neither → skipped, logged as skipped
Status Values
  • ✓active — receives copy trades
  • !pending — onboarded, not yet active
  • —paused — skipped from copy trades
Log Files
  • Llogs/dip_scan.log — hourly pipeline run log
  • Cdata/copy_log.json — all copy trade fills
  • Sdata/rh_copy_seen.json — RH order_id → {attempts, done}; failed copies retry up to 5× before giving up
  • Pwebsite-dip/perf_trades.json — trade log + P&L
  • Hdata/customer_health_state.json — last known failing customer/broker pairs; diffed each run to decide whether to email
Customers Page — Admin Tools
Edit — opens a form for name/broker credentials (capital is not editable — it is always the broker account's live equity), wired to POST /api/customers. Credential fields are always blank on open and only sent if retyped, so leaving one blank never overwrites the existing key.

Click a customer's name — opens their live performance on the fly (no page navigation): per-broker Portfolio/Buying Power/Day/Week/Month P&L, open positions with unrealized P&L, and recent copy-trade activity from copy_log.json. Source: same GET /api/customer_account?id=… call.

Copy Link — generates (if missing) the customer's access_token and copies their personal client-portal login link to the clipboard, for the admin to send directly. No shared password exists for the client portal.