A momentum/breakout trading system for the Egyptian Exchange — built to be measured before it is trusted.
The Python package inside this repository is egx-trader; the CLI is egx.
Why · Backtest · Architecture · Hard-won lessons · Rules · Quickstart
Warning
Research software, not investment advice. Backtest results are not predictions. Automating a broker account may breach that broker's terms and put the account at risk. See NOTICE.md.
Important
Nothing in this repository can place an order. The execution path is deliberately unfinished, and the parts that exist are gated behind explicit configuration and per-order human approval.
| 96 instruments |
99% session coverage (merged feeds) |
91 non-trading days derived, not listed |
378 tests |
0 orders it can place |
The system it replaces was a mean-reversion scanner: it emitted BUY only when
rsi <= 30 or rsi <= 40. During a stock's run from ~48 to ~609 EGP, RSI sat in
the 70s–90s and price was far above both moving averages, so every rule fell
through to HOLD. It was structurally incapable of buying strength — not a
tuning problem, a property of the rule family.
This targets the opposite: breakout entries with a wide trailing exit. And unlike its predecessor, it is measured against a cost model that reflects what the market actually does to an order.
Replaying 2022–2026 on the stock above, 20,000 EGP notional:
| Strategy | Trades | Hit rate | Net |
|---|---|---|---|
🟢 breakout_momentum |
8 | 38% | +19,487 EGP |
⚪ mean_reversion (baseline) |
1 | 100% | +7,389 EGP |
The breakout entered the run twice and captured +98% on the second. The baseline took one trade in four years and was absent throughout.
Caution
What that does not show. The exit gave back a lot — it captured 166→331 and missed 331→564, stopped out when a −20.0% limit-down session breached a 3×ATR chandelier. And eight trades on one symbol is a case study, not evidence. It answers "would this have caught that move?" It does not answer "does this make money?"
flowchart LR
subgraph ingest["📥 Ingest"]
Y[Yahoo] --> M[merge<br/>per-bar provenance]
E[EODHD] --> M
T[TradingView CSV] --> M
end
M --> Q{{"quality gates<br/>price band · splits · gaps"}}
Q --> F["indicators<br/>Wilder RSI · ATR · Donchian"]
F --> S["strategy<br/>breakout_momentum"]
S --> R{{"risk engine<br/>every gate is an ENTRY gate"}}
R --> K["order ticket<br/>one-way state machine"]
K --> H{{"human approval"}}
H -->|approved| B["broker adapter"]
H -->|unconfirmed| X["discarded — expiry never means execute"]
B --> N["NotImplementedError<br/>broker DOM unimplemented"]
classDef gate fill:#fff4d6,stroke:#e0b64a,stroke-width:2px,color:#3d2f00;
classDef stop fill:#ffe3e0,stroke:#f85149,stroke-width:2px,color:#5c1a14;
class Q,R,H gate;
class N,X stop;
The chain ends in a wall on purpose. See Status.
gantt
title EGX session — Sunday to Thursday, Africa/Cairo
dateFormat HH:mm
axisFormat %H:%M
section Phases
Pre-open — closes at a random moment :done, po, 09:30, 30m
Continuous trading :active, ct, 10:00, 255m
Closing auction : ca, 14:15, 10m
Trading-at-close : tac, 14:25, 5m
The pre-open ends at a random moment between 09:50 and 10:00 — a deliberate anti-gaming measure, and a reason no logic here may assume a fixed open. Settlement is T+2 by default; T+0 requires an eligible board and a broker subscription and an advanced limit order.
Every one of these was found by running code against the live market. They are the main reason this repository might be useful to someone else building on EGX.
🗓 Egypt's public holidays are not the exchange's holidays
EGX traded on Police Day and June 30 in 2026 but was closed on both in 2024, and the Islamic and Coptic observances move annually. Hand-writing the calendar produced two false holidays and omitted every Eid. It is now derived from the trading record — a date is closed when under 15% of a liquid basket has a bar for it. That found 91 non-trading days against 7 hand-written, and it matters because a missing holiday corrupts settlement dates.
📉 range=max returns monthly bars
Yahoo serves them while still reporting interval=1d. 10y → 2089 bars at 1-day
spacing; max → 275 at 31-day spacing. Backtesting on those unnoticed would produce
fiction.
🕳 Consecutive bars are not consecutive sessions
Yahoo drops 22–30% of EGX sessions at random, so one bar step can span several days.
Judging that against a single-day price band flagged 296 "corrupt" bars across 59
symbols, nearly all false. The gate now compounds its allowance over sessions
actually elapsed: allowed = (1 + limit) ** sessions - 1.
🧩 No single feed is sufficient
| Provider | Session coverage |
|---|---|
| Yahoo | 81% |
| EODHD | 92% |
| Merged | 99% |
They miss different sessions. Every merged bar carries provenance, because the feeds disagree on prices and a backtest that cannot say which vendor supplied a bar cannot distinguish an edge from an artefact.
📐 RSI means Wilder's smoothing
An SMA of gains and losses is a different indicator: after 20 declining bars then 16 rising, the SMA version reads 100 while Wilder reads 69. A "sell above 70" rule fires on one and not the other.
🚫 TradingView has no data API at any tier
And its terms forbid automated collection. Even the CDP bridge approach has
exportData() blocked. Manual CSV export only, which is why the importer here is a
file reader rather than a client.
These are enforced by tests, and breaking them makes the system unsafe rather than merely wrong.
| Rule | Why |
|---|---|
| 🔒 Unknown never reads as permissive | No board data → not T+0 eligible. No sourced Sharia label → excluded from the Sharia universe. An unsourced label is not a label. |
| 🚪 Nothing may block an exit | Every risk gate is an entry gate. A cap that blocks a sell does not reduce risk, it creates it — you would hold a position the software refuses to release. Exits face settlement alone. |
| ⏳ Expiry never means execute | An unconfirmed ticket is discarded. "No objection, so proceed" would turn being away from the desk into a trading decision. |
| 🕳 A missing indicator is missing | Never zero, never forward-filled. |
The ticket lifecycle encodes the last two — every terminal state is a dead end, and there is no path backwards:
stateDiagram-v2
direction LR
[*] --> pending
pending --> approved : human approves
pending --> rejected : human declines
pending --> expired : timeout
approved --> placed : broker accepted
approved --> failed : broker refused
placed --> [*]
rejected --> [*]
expired --> [*]
failed --> [*]
Rewinding a ticket is how the same order gets submitted twice, so the state machine refuses it and tells you to create a new one.
uv venv --python 3.12
uv pip install -e ".[dev]"
cp .env.example .env
.venv/bin/pytest.env needs at minimum a market-data source. See .env.example for
every option — execution mode, universe mode, providers, risk limits.
| Command | What it does |
|---|---|
egx universe |
instrument master and coverage gaps |
egx calendar |
trading days, session phases, T+2 settlement dates |
egx data <symbols> |
fetch daily OHLCV and report quality |
egx audit |
quality gates across the whole universe |
egx verify-calendar |
cross-check holidays against the trading record |
egx providers |
which data providers are configured |
egx compare-providers |
measure providers against each other on session coverage |
egx dashboard |
static HTML snapshot |
egx desk |
live dashboard with controls (localhost only) |
egx record |
record intraday bars for the session |
src/egx_trader/
├── market_calendar/ session clock, holidays, settlement arithmetic
├── universe/ instrument master: Sharia flag, board, T+0 eligibility
├── data/ OHLCV + quality gates; providers/ and intraday/
├── features/ indicators (Wilder RSI, ATR, Donchian, turnover)
├── strategies/ breakout_momentum | mean_reversion (baseline)
├── backtest/ event-driven engine with the EGX cost model
├── portfolio/ lot-level ledger, cost basis, tax reserve
├── risk/ pre-trade gates, order budget, kill switch
├── execution/ order types, ticket lifecycle, adapters
├── control/ local desk server
├── dashboard/ self-contained HTML
└── notify/ desktop notifications
market_calendar rather than calendar — the latter shadows a stdlib module.
| Phase | Scope | State |
|---|---|---|
| 0 | Config, calendar, universe, data + quality gates | ✅ done |
| 1 | Indicators, strategies, backtest engine | ✅ done |
| 2 | Ledger, risk engine | ✅ done |
| 3 | Ticket lifecycle, adapters | 🟡 partial — broker integration unfinished |
| 4 | Unattended execution | ⬜ not started |
The broker integration is unfinished on purpose. Filling an order ticket requires
reading a specific broker's live DOM, and inventing selectors would produce an adapter
that silently clicks the wrong control on a real account. It raises
NotImplementedError naming what it needs instead.
The tests encode reasoning, not just behaviour — most carry a docstring explaining which real failure they prevent. If a test looks arbitrary, that docstring is the place to check before changing it.
There is a .claude/skills/egx-trading skill
collecting the domain rules and the gotchas above, so an AI assistant working on this
starts with the context rather than rediscovering it.
MIT — see LICENSE. NOTICE.md covers what that means for software that touches real money.
Built for an exchange that closes at 14:30 and settles two days later.