Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Verso

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.

Setup

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.

How it works

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.

Strategies

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=10

H1 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.

What the data said

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.

Backtesting

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 table

The 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.

Roster backtest — what the bot actually ships

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-overlap to 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).

Sweeping every possibility

uv run python -m verso.sweep --from 2025-01-01 --to 2026-07-31 --jobs 8

Grid-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.

Walk-forward folds

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 8

Costs ~N× the runtime, and lower --train-fraction to keep the test blocks wide. Read the columns in this order:

  1. folds+ — how many folds were profitable. 4/4 at +0.15R is far better evidence than +0.50R that one fold paid for.
  2. worst — the fold that went against you is the one you'd have lived through.
  3. sd — if the spread across folds is as large as the mean, it's noise.
  4. 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.

Layout

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.

Development

uv run pytest -q
uv run ruff check src tests
uv run ruff format src tests

No 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.

Caveats

  • Demo by default. Flip CAPITAL_LIVE only when you mean it.
  • The suggested position size assumes $1 of price move = $1 per unit. Verify against lotSize/marginFactor for your account before trusting it with real money.
  • Signals are not advice. The bot can be wrong; you own the trade.

About

Gold (XAUUSD) auto-trading bot for Capital.com — session auth, market data, and automated position management on the demo API

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages