An end-to-end daily equity momentum system: it ingests market data, screens a universe for liquidity and trend, detects volatility-contraction breakouts, sizes positions by risk rather than by capital, and persists open trades between runs.
The design goal was a system with no discretionary inputs. Every decision — what to buy, how much, and when to exit — is a pure function of price and a small set of declared parameters, so a run is reproducible and any change in behaviour is traceable to a parameter rather than to judgement.
The system trades a single, well-documented pattern: a sharp advance, a shallow pause, then a breakout to new highs.
← breakout: close clears the peak
peak ┌──○ ○
│ ╲ base ╱
│ ○──○──○──○──○──○─╱ ← shallow consolidation,
impulse ╱ bounded in depth and duration
╱
╱ ← advance of ≥30% into the peak
○──○──○────╱
└─ lead-in
Why this shape. An impulse leg identifies names under genuine accumulation. The constraint that matters is the base: a stock that gives back more than a quarter of its advance has usually broken its trend rather than digested it, so capping retracement at 25% is what separates a pause from a reversal. Bounding the base in duration as well as depth does the same job on the other axis — a base that drags past 40 days signals momentum decay, not accumulation.
Each stage is a separate module, and each one only narrows the candidate set. Cheap filters run first so the expensive pattern analysis sees as few names as possible.
| Stage | Module | Rule |
|---|---|---|
| 1. Screen | screener.py |
Close ≥ $3.00, 50-day average volume ≥ 300k, close above the 50-day SMA |
| 2. Signal | signaler.py |
Impulse ≥ +30% into a 63-day peak; base of 4–40 days retracing < 25%; today's close clears the peak |
| 3. Size | executor.py |
Risk 2% of equity per trade; reject any setup whose stop exceeds 1× ATR(14) |
| 4. Exit | portfolio.py |
Close below the 10-day SMA |
Size is derived from risk, not from available capital:
shares = (equity × risk_per_trade) / (entry − stop)
A wide stop therefore buys a small position and a tight stop a large one, so every open trade carries the same dollar loss if it fails. This is what makes the return stream comparable across names of very different volatility — without it, a single high-volatility position would dominate portfolio risk.
The stop ≤ 1× ATR(14) filter is the complement: it rejects setups where the
stop is so wide relative to normal daily range that the sizing formula would
produce a position too small to be meaningful.
config.py All tunable parameters — universe, thresholds, risk limits
data.py Market data acquisition (yfinance), normalised to OHLCV
screener.py Stage 1 — liquidity and trend filter
signaler.py Stage 2 — breakout pattern detection, ATR
executor.py Stage 3 — risk-based position sizing
portfolio.py Position persistence and exit logic
main.py Daily driver wiring the stages together
tests/ Unit tests for each stage
Two deliberate choices:
- Parameters are isolated in
config.py. No strategy threshold is written inline. A parameter sweep or a walk-forward study touches one file, and the strategy logic never hides a magic number. - Exits are evaluated before entries. Capital freed today is visible to today's entry decisions, and a name sold this morning cannot be re-bought in the same pass.
pip install -r requirements.txt
python main.pyOutput for one cycle:
Systematic Momentum Trading System
--- Checking exits for 1 position(s) ---
HOLD MU: trend intact
--- Scanning for breakout entries ---
3 of 20 names passed the screen
BUY NVDA: 141 shares @ $265.92, stop $251.75 (risk $1,998)
Open positions are written to active_positions.csv, which is the system's
entire state — it can be inspected or edited by hand between runs.
pytest tests/Each test isolates one rule: a single synthetic setup passes cleanly, and every other case perturbs exactly one property of it to confirm which rule rejects it.
Stated plainly, since these bound what the system can be claimed to do:
- Signals, not execution. Orders are printed, not routed. Slippage, partial fills and market impact are not modelled.
- Fixed universe. Twenty large-cap technology names, so the results carry sector concentration and are not a broad-market claim. The universe is also survivorship-biased — it is today's list, not a point-in-time membership.
- Daily bars only. Nothing intraday; the exit can only trigger at the next daily close.
- Free data.
yfinanceis adequate for liquid large caps but is not a research-grade feed;data.pyis deliberately the only vendor-aware module so it can be swapped.
Full write-up: SystematicTrading.pdf.