A Telegram bot that watches gold (XAUUSD, Capital.com epic GOLD) and messages you a trade
idea — direction, entry, stop loss, take profits, R:R. You place the trade by hand. The bot
holds read-only API access and never sends an order.
uv sync
cp .env.example .env # then fill it in
uv run verso.env:
| Var | Notes |
|---|---|
CAPITAL_API_KEY |
Settings > API integrations on capital.com (2FA required) |
CAPITAL_IDENTIFIER |
your login email |
CAPITAL_PASSWORD |
the API key's custom password, or your account password |
CAPITAL_LIVE |
false (default) uses the demo base URL |
TELEGRAM_BOT_TOKEN |
from @BotFather |
TELEGRAM_CHAT_ID |
your own chat id — message the bot, then read it from getUpdates |
Optional overrides with defaults: STRATEGIES=ema_cross, TIMEFRAMES= (falls back to
TIMEFRAME), TIMEFRAME=HOUR, POLL_SECONDS=300, MAX_SPREAD=0.60,
MIN_RISK_REWARD=2.0, COOLDOWN_SECONDS=14400, RISK_PERCENT=1.0, STATE_PATH=var/state.json.
main.py polls GET /prices/GOLD once per configured timeframe, drops the still-forming
candle, and hands the closed bars to every configured strategy. The loop aligns to bar
closes (default +10s buffer) so a signal is typically on Telegram within ~10–15s of the
M30 close — not up to a full POLL_SECONDS later. WebSocket streaming is unnecessary for
closed-bar strategies; REST is enough for one epic. Each returns a Signal or
nothing; a strategy that raises is skipped for that tick rather than silencing the rest. Every
signal passes the gates — self-consistent levels, market open, spread
under cap, R:R above minimum, no duplicate inside the cooldown — before the notifier renders and
sends it. state.json persists what was sent, so a restart does not re-fire.
The bot answers exactly one command, /status, from the configured chat only: uptime, when it
last polled and when it polls next, what it is watching, how many signals it has sent, the last
one, and the last error if there was one. It is answered entirely from memory and state.json,
so asking whether the bot is alive never itself hits Capital.com.
Seven, each on its own cooldown so they don't mute each other. The right-hand column is what survived 4-fold walk-forward validation on real gold, 2025-01-01 → 2026-07-30 (18,653 M30 bars, 246 configurations); see "What the data said" below.
| name | rule | walk-forward verdict |
|---|---|---|
ema_pullback |
in an EMA-defined trend, price pulls back to the fast EMA and closes back in trend | 4/4 folds profitable — best holdout total (+45.8R @M30) |
bollinger_squeeze |
Bollinger bandwidth at an N-bar low, then a close breaks the band | 4/4 folds profitable (+30.5R holdout @M30) |
session_breakout |
first close out of the NY-open (13:00 UTC) opening range | 4/4 folds on M30 and H1 — most consistent config in the sweep (worst fold +0.19R, sd 0.05) |
sr_bounce |
first rejection of a confirmed swing high/low (S/R retest) | not in original sweep; tuned pivot=2;lookback=40;touch_atr=0.5 @M30 solo +0.11R / +87R, ~202 sigs/mo |
donchian_breakout |
close breaks the prior N-bar high/low | 4/4 with ch=20 on H1/H4; floods M30 with weak signals |
ema_cross |
EMA 20 crosses EMA 50 | did not survive |
rsi_reversion |
RSI(14) crosses back out of 30/70, with an EMA(200) trend filter | too few trades to judge |
All seven size the stop from ATR, take profits at 2R/3R, and clamp every level to the
instrument's dealing rules through the one shared levels.py.
Pick what runs live in .env — the bot builds the full strategy × timeframe matrix, and a
strategy may carry the tuning a sweep validated. The configuration that won the roster
backtest (see below) is:
STRATEGIES=ema_pullback,bollinger_squeeze:period=20;band_mult=1;squeeze_lookback=20,sr_bounce:pivot=2;lookback=40;touch_atr=0.5
TIMEFRAMES=MINUTE_30
HTF_FILTER=true
HTF_TIMEFRAME=HOUR
HTF_EMA_PERIOD=50
COOLDOWN_SECONDS=3600
POLL_SECONDS=120
BAR_CLOSE_BUFFER_SECONDS=10H1 bias → M30 entry: when HTF_FILTER=true, every M30 signal must agree with the H1 EMA(50) side (close above → BUY only; below → SELL only). That is the standard higher-TF confirm / lower-TF entry pattern — we keep M30 for entry (measured edge) and use H1 only as a filter, not M15.
Live gates also block an opposite alert while the last signal is still between its SL and TP1 (so a morning BUY is not followed by an afternoon SELL hedge), and sr_bounce defaults to a trend filter (longs only above EMA50, shorts only below) so it stops fading strong trends. Telegram messages note signals are freshest within ~15 minutes of the bar close.
That is the activity-first ship: ~280 Telegram signals/month (~9/day) at +0.12R
expectancy on the roster sim — pullback + squeeze + tuned sr_bounce. It beats S/R alone
on total R while keeping the high message rate. The four-pack that also ran NY
session_breakout was busier (~302/mo) but worse money (+81R vs +92R). Stay on M30.
M15/M5 roughly double the message rate but the 0.30 spread eats the edge. Do not add H1
into the same matrix either: the six-pair M30+H1 set collapses under crowding.
Adding pairs does not multiply the trades you can take — it can cost you money. You have one account, so while a trade is open the other pairs' signals go past unreachable. Measured on real 2025–2026 gold (18.9 months, one trade at a time, exits on M30 bars):
| roster | signals/mo | trades | crowded out | total |
|---|---|---|---|---|
| 4-pack (+ NY session) @M30, 1h | 302 | 828 | 85% | +81.2R |
pullback + squeeze + sr_bounce @M30, 1h (no HTF) |
280 | 772 | 85% | +92.4R |
| same + H1 EMA50 bias filter (shipped) | 127 | 627 | 74% | +86.1R @ +0.14R exp |
sr_bounce:pivot=2;lookback=40;touch_atr=0.5 @M30 alone |
202 | 776 | 80% | +87.3R |
ema_pullback + bollinger_squeeze + NY session_breakout @M30, 1h cooldown |
100 | 630 | 67% | +84.7R |
ema_pullback + bollinger_squeeze @M30, 2h cooldown |
71 | 503 | 62% | +99.3R |
ema_pullback + bollinger_squeeze @M30, 1h cooldown |
78 | 503 | 66% | +99.4R |
ema_pullback + bollinger_squeeze @M30, 4h cooldown |
64 | 503 | 59% | +89.1R |
ema_pullback @M30 alone |
31 | 426 | 27% | +74.1R |
| same two @M15, 1h cooldown | 145 | 1005 | 63% | −10.0R |
same two + session_breakout, on M30+H1 (6 pairs) |
143 | 461 | 76% | +3.4R |
ema_pullback:sl_atr_multiple=2.5 @M30 (previous deploy) |
31 | 271 | 54% | +34.3R |
Two lessons in that table. The squeeze strategy earns money exactly where the pullback chops
(its in-roster expectancy is +0.24R), so the pair beats either alone — that is what a real
diversifier looks like. The NY session breakout is the next clean add for activity: it is
clock-driven rather than shape-driven, so it lands on bars the other two often skip, and the
three-pair M30 roster still beats its best solo member. And the six-pair M30+H1 matrix
collapses to +3.4R despite three times the signals: weaker pairs take the seat first. Run
verso.roster on your intended set before adding anything — it now accepts tuned specs
(--strategy 'bollinger_squeeze:period=20;band_mult=1;squeeze_lookback=20'), so you can
measure exactly what .env will run.
Measured on real Capital.com bars, 2025-01-01 → 2026-07-30, 246 configurations (6 strategies × 3 timeframes × parameter grids), 4 anchored walk-forward folds, ranked in-sample and scored on untouched test blocks.
Context first: gold went +55% over this window, and almost all of it in the first half (+58.6%); the second half was −2.5% with a spike to 5597 and back. So in-sample results flatter trend-followers, which is exactly why the folds matter.
32 of 246 configurations were profitable in all four folds with a real sample. The best by consistency and holdout total, not by peak:
| config | train exp | holdout exp | holdout total | folds+ | worst fold | sd |
|---|---|---|---|---|---|---|
ema_pullback 20/50 sl=1.5 @M30 |
+0.16R | +0.22R | +45.8R | 4/4 | +0.11R | 0.07 |
session_breakout 13:00 r=3 w=18 @H1 |
+0.12R | +0.26R | +35.1R | 4/4 | +0.19R | 0.05 |
ema_pullback 10/100 sl=2.5 @H1 |
+0.37R | +0.41R | +26.7R | 4/4 | +0.32R | 0.08 |
bollinger_squeeze 20/1σ/20 @M30 |
+0.23R | +0.19R | +30.5R | 4/4 | +0.05R | 0.13 |
donchian_breakout ch=20 sl=2.5 @H1 |
+0.37R | +0.33R | +26.1R | 4/4 | +0.05R | 0.29 |
Both strategies added in the second round earned their place: the session_breakout family
works at the New York open (13:00 UTC) across both range widths and both intraday
timeframes — and not at the London open (7:00), which is the kind of half-failure that makes
the working half credible. The bollinger_squeeze family is profitable across periods 10–30
at 1σ bands with a 20-bar squeeze lookback.
What makes this credible rather than a lucky pick: the whole ema_pullback family worked —
across both stop multiples and all three timeframes — and the holdout matched or beat training
rather than collapsing. A single winner surrounded by failures is curve fitting; a family is a
weak but real edge.
Two more honest notes: ema_cross, the strategy this project shipped first, did not survive
the folds (−0.02R on H1 over the full range). And in the flat second half ema_pullback still
returned +0.20R with BUY and SELL both at +0.20R — it did not need the uptrend.
A lesson the sweep alone cannot teach: this project once deployed
ema_pullback:sl_atr_multiple=2.5 because it had the better per-trade expectancy. The roster
backtest showed that its wider stop makes trades last ~40 bars instead of ~15, so 54% of its
own signals arrive while the account is busy — it earned +34.3R where the default sl=1.5
earned +74.1R from the very same signal stream. Rank per-trade numbers in the sweep, but pick
the deployment with verso.roster, which charges for the account's time. The same test showed
a 2h cooldown beats 4h on M30 (more signals, both slices improved) — that is where
COOLDOWN_SECONDS=7200 comes from.
Expectancy near +0.2R with a ~40% win rate means long losing runs are normal: the measured worst streak was 10 losses in a row. That is survivable only if position size is small enough that ten consecutive losses is uncomfortable rather than fatal.
Measure before you trust. Reproduce everything above with the commands below.
# 1. cache real bars (needs .env; ~10+ paged calls per timeframe for 18 months)
uv run python -m verso.fetch --timeframe HOUR --from 2025-01-01 --to 2026-07-31
uv run python -m verso.fetch # MINUTE_30 + HOUR + HOUR_4
# 2. measure, offline and repeatable
uv run python -m verso.backtest --timeframe HOUR --from 2025-01-01 --to 2026-07-31
uv run python -m verso.backtest --all-timeframes # comparison tableThe fetcher writes data/GOLD_<RESOLUTION>.jsonl (git-ignored), resumes without re-fetching,
and ends with a coverage report — first bar, last bar, largest gap, and a loud warning plus
exit code 1 if the API served less than you asked for. Check that before believing any result:
the demo API may not hold 18 months of intraday history.
The backtester never touches the network. Results are in R multiples (profit ÷ initial risk), with expectancy per trade as the number that matters. It prints its assumptions under every report; the ones that flatter results if you forget them:
- a bar touching both stop and target counts as a stop — intrabar order is unknowable
- entry is worsened by
--spread(default 0.30), so every R is net of spread - market-open, opening-hours and spread-cap gates are skipped, not faked — OHLC history carries no bid/ask or market status
- fills are exact at the level even if the bar gapped past it, and there is no commission or financing cost
- one trade at a time, like a human; overlapping signals are counted and reported as skipped
Two caveats no report can fix: 2025–2026 gold trended hard, so a trend-following strategy will flatter itself on this sample, and any parameter you tune against these bars is fitted to them.
Strategies see a bounded trailing window (--window, default 500 bars, auto-widened to each
strategy's warm-up) which makes a run O(n) instead of O(n²) — several times faster, with trades
verified identical to an exhaustive pass in tests/test_window.py. --exhaustive forces the
slow path.
backtest and sweep measure one strategy on one timeframe, alone. The live bot runs the whole
matrix at once, competing for a single account. That is a different thing, and this measures it:
uv run python -m verso.roster --from 2025-01-01 --to 2026-07-31- merges every pair's gated signals into one chronological stream
- enforces one trade at a time across the roster (
--allow-overlapto lift it), and reports per pair how many signals were crowded out and what share it actually reached - settles exits on the finest cached series, because an M30 bar resolves stop-vs-target inside an H4 bar that H4 data simply cannot see — the same trade can score −1R or +2.5R depending on which series you ask, and the finer answer is the truer one
- shows each pair's contribution to the roster beside its solo result, so you can see who is carrying and who is only taking turns
Read the reach% column before adding another strategy. A pair reaching 8% of its signals is
not contributing a strategy, it is contributing a rounding error.
--jobs N parallelises signal generation only; merging, crowding and simulation stay sequential
so the output is byte-identical at any job count (there's a test for that).
uv run python -m verso.sweep --from 2025-01-01 --to 2026-07-31 --jobs 8Grid-searches every strategy × timeframe × parameter set (111 configs by default, ~3 min with 8 jobs) and ranks them. The point is not the ranking — it's the holdout:
- tunes on the first 70% of bars, scores on the untouched remainder
- ranks on in-sample only; ranking on the holdout would leak it
- prints both side by side, so a config that only works in-sample is obvious
--min-trades(default 20) demotes small-sample flukes to a separate section- the verdict states how many configs were tried and says bluntly when the winner's holdout is negative or much worse than training
Read the holdout columns first. With 111 attempts on one sample, the top in-sample row is partly the luckiest fit, not the best strategy — that's arithmetic, not pessimism. A config worth trading is one whose holdout expectancy survives.
One holdout is thin evidence: try 111 configs and something will clear it by luck. --folds N
cuts the range into N successive train→test blocks (anchored, so training grows forward the way
a re-tuned bot's history would):
uv run python -m verso.sweep --from 2025-01-01 --to 2026-07-31 --folds 4 --train-fraction 0.5 --jobs 8Costs ~N× the runtime, and lower --train-fraction to keep the test blocks wide. Read the
columns in this order:
folds+— how many folds were profitable. 4/4 at +0.15R is far better evidence than +0.50R that one fold paid for.worst— the fold that went against you is the one you'd have lived through.sd— if the spread across folds is as large as the mean, it's noise.- the in-sample rank, last.
The verdict calls out a config carried by a single fold, folds that disagree in sign, and folds
too thin to mean anything. --folds 1 (default) keeps the original single-split behaviour.
| Module | Role |
|---|---|
domain.py |
shared types: Candle, Market, Signal, Direction, Timeframe |
config.py |
.env settings |
capital.py |
the only Capital.com I/O; auth, re-auth, rate limiting |
indicators.py |
pure EMA/ATR/RSI/Donchian; warm-up slots are None |
levels.py |
the one definition of entry/stop/target arithmetic |
strategy.py, strategies.py |
the first four strategies; each returns Signal | None |
strategies_squeeze.py |
bollinger_squeeze: volatility contraction, then the break |
strategies_session.py |
session_breakout: the NY-open opening range |
registry.py |
strategy discovery by name |
gates.py |
pure send/no-send decision |
state.py |
JSON persistence for cooldown |
notify.py |
message formatting (pure) + Telegram sending |
commands.py |
the one inbound command, /status: liveness report (pure) + long-poll |
main.py |
the poll loop |
history.py |
the on-disk candle cache (data/*.jsonl) |
fetch.py |
backfill CLI: pages the price API into the cache |
backtest.py |
offline simulator + report, reads only the cache |
roster.py |
the whole strategy x timeframe matrix as one competing stream |
sweep.py |
grid search with a train/holdout split |
Adding a strategy means writing one evaluate() — it should not touch the client, gates, or
loop.
uv run pytest -q
uv run ruff check src tests
uv run ruff format src testsNo test touches the network; fixtures come from the API spec's own examples. API reference and
the product spec live in docs/ — start at docs/README.md.
- Demo by default. Flip
CAPITAL_LIVEonly when you mean it. - The suggested position size assumes $1 of price move = $1 per unit. Verify against
lotSize/marginFactorfor your account before trusting it with real money. - Signals are not advice. The bot can be wrong; you own the trade.