A Telegram bot that turns market, macro and geopolitical data into a pedagogical briefing — and shows the maths behind every single number.
🇬🇧 English · 🇫🇷 Version française
⚠️ Educational project. Not financial advice, not a trading signal. Every order it shows is hypothetical, on a fictional account. Read the disclaimer.
Most market bots either dump numbers at you, or let a language model narrate a story around them. This one does neither. It computes everything in Python first — then lets a model write prose around the result, and rejects that prose if a single number was invented.
The design rule is: compute, then narrate. A finding that the code cannot compute does not exist. The failure mode is dryness, never fabrication.
Three things go out on the channel, on weekdays:
| When (UTC) | What | |
|---|---|---|
| Digest | 05:30 · 15:45 · 20:15 | Up to three messages: the briefing (the facts), the décryptage (what the facts imply, with a fully-worked order) and the P&L reminder (open positions of every track, grouped per AI — silent when nothing is open) |
| Alert | hourly, 06:00–21:00 | A notable move, with its context and a fully-worked order. 100 % deterministic — no model is involved |
| Prune | Sunday 04:00 | Bounds the database, VACUUM |
The output language is French (the channel's audience). The codebase and docs are English.
This is a deliberate architectural decision, and it is the reason the project is cheap to run.
The default LLM backend is the Claude CLI in headless mode (claude -p), authenticated by a
subscription OAuth token (CLAUDE_CODE_OAUTH_TOKEN, generated once with claude setup-token).
Not an API key. Usage counts against the subscription quota, so the marginal cost of a digest is
zero — there is no per-token bill at the end of the month.
| Component | Model | Cost |
|---|---|---|
| Briefing (message 1) | Claude CLI — subscription | 0 |
| Décryptage (message 2) | Claude CLI — subscription | 0 |
| Alerts (4–8× a day) | none — fully deterministic | 0 |
| Fallback | Gemini API | only if the Claude CLI is unavailable |
Gemini is a fallback, not the engine. It runs only when the CLI is missing, its token expired, or
it timed out — and a circuit breaker stops the bot from hammering a failing backend. Set
models.digest_backend / decrypt_backend to "gemini" if you would rather use the API.
And the bot still publishes with no LLM at all. Alerts are 100 % deterministic by design, and both digest messages fall back to a deterministic template. Without any model you get a drier briefing — never a missing one.
Four hardening details in llm_claude.py that took a debugging session each — do not remove them:
ANTHROPIC_API_KEYis stripped from the subprocess environment. Otherwise the CLI silently switches to metered API billing, which is exactly what this design avoids.MAX_THINKING_TOKENS=0— the CLI's default extended thinking blows past the timeout.--system-promptreplaces Claude Code's agentic system prompt; without it the CLI tries to call tools (Bash, Write, WebSearch…) instead of simply writing the text.--strict-mcp-config --mcp-config {}ignores any configured MCP server (latency + interactive auth), and the binary is called by absolute path with an augmentedPATH— cron'sPATHdoes not include~/.local/bin.
📈 Marchés | 13/07 15:04 (Europe/Zurich) | Après-midi, Wall Street ouvert
Depuis le dernier point (il y a 7,6h) :
VIX ▲9,18% (regain de nervosité), EUR/USD ▲0,25%, Euro Stoxx 50 ▼0,12%
Indices :
S&P 500 : 7 575 USD ▲0,42%
NASDAQ : 26 282 USD ▲0,29%
Nikkei : 67 243 JPY ▼1,92% 🔴
Risque & taux :
VIX : 16,41 ▲9,18% — nervosité en hausse malgré un niveau encore modéré ; structure en
contango (VIX/VIX3M 0,88) = stress non aigu. US 10 ans : 4,587% ▲0,39%.
Matières premières :
WTI : 74,03 USD ▲3,67% 🔥
Brent : 78,96 USD ▲3,88% 🔥
⚠️ Stocks bruts US en build (+2 998 kbbl) mais distillats en fort draw (▼4 980 kbbl).
Ce qui anime les marchés :
• Frappes US sur 140 cibles en Iran après attaque dans Hormuz (Guardian, ForexLive)
• TSMC : revenus juin ▲68%, soutien au NASDAQ et SOX (CNBC)
🛰️ Signaux géopolitiques (ShadowBroker + GDELT) :
• Hormuz : 2 actifs militaires détectés — Goldstein -10/10 (max conflit), 169 articles
🧭 Lecture intermarché :
• Régime neutre/mixte — équité composite ▲0,12% ; courbe 3M/10Y à +0,88 pt (normale)
• Ratio cuivre/or sur 5j : ▲4,17% — appétit pour le risque cyclique
• COT notable : cuivre net long +64 272 (potentiel signal contrarien si excès)
🔗 Sources
• CNBC Markets · ForexLive · MarketWatch ← real, clickable links
🔎 Vérification d'hier
Biais mixte du 2026-07-10 : indices -0,54% en moyenne.
Verdict : partiellement vérifié.
🧠 Ce qui ne colle pas
• Corrélation S&P 500 / Or — Sur 60 jours la corrélation vaut +0,55 (IC95 +0,35…+0,71),
contre +0,25 sur un an : la relation a changé.
Le calcul :
r(60j) = +0,55, n=60, IC95 = [+0,35, +0,71]
r(250j) = +0,25, n=249
l'IC recent EXCLUT la valeur longue -> rupture
Confiance : haute.
Contrôles effectués : GDX~Or résidu z=0,0 (seuil 2,0) · XLE~Brent résidu z=0,8 (seuil 2,0) ·
SOX~NASDAQ résidu z=-0,6 · XLF~US10Y résidu z=0,3 · BTC~NASDAQ résidu z=0,9 ·
VIX 30e percentile · MOVE : séance non ouverte (cotation de la séance du 2026-07-10) ·
Cuivre 93e percentile · Pétrole WTI choc 1,1σ (seuil 2,0) · Énergie (XLE) choc 1,4σ
🎲 Simulation
Pas de nouvelle simulation aujourd'hui — aucun signal directionnel exploitable.
📐 L'ordre, si vous vouliez le passer
Exemple pédagogique — NON suivi, NON comptabilisé au tableau de bord. Le bot n'a PAS pris ce
pari : il montre comment la donnée du jour se transforme en ordre.
Énergie (XLE) a fait +2,44% aujourd'hui, soit 1,7 fois son écart-type quotidien (1,47% sur 20j).
Ordre : achat de 53,60 unités de Énergie (XLE) à 56,2200 USD.
Le calcul :
ATR 20j = 1,1521 — l'amplitude quotidienne moyenne. C'est l'unité de risque : les niveaux sont
posés en multiples d'ATR, pas en chiffres ronds arbitraires.
stop = 55,0680 USD (2,0% du prix, soit 1 x ATR)
objectif = 58,5241 USD (4,1% du prix, soit 2 x ATR)
risque = 50,00 CHF, soit 1% d'un compte fictif de 5000 CHF
taille = risque / (ATR x 1 x taux) = 50,00 / (1,1521 x 1 x 0,8098) = 53,60 unités
taux USD/CHF = 0,8098 (le P&L est reconverti au taux de CLÔTURE)
gain visé = 100,00 CHF si l'objectif est touché -> R:R 2,0:1 ; sortie au marché après 10 séances.
📖 Le mot du jour
ATR (Average True Range) — Amplitude moyenne d'une séance, en points. Sert à dimensionner un
stop : un stop plus serré que l'ATR sera touché par le bruit normal du marché.
Formule : TR = max(H−B, |H−C_préc|, |B−C_préc|) ; ATR = moyenne lissée (Wilder) sur 20 séances
⚠️ Pédagogique et hypothétique — PAS un conseil financier ni un signal.
📖 Pour approfondir « ATR » : investopedia.com/terms/a/atr.asp
Every digest carries a fully-computed order — from the tracked simulation if a signal fired, from the pedagogical block otherwise. On a quiet session it still appears, and says so: "Séance calme : sous 1,0 σ, ce mouvement n'a rien d'exceptionnel. L'ordre ci-dessous est purement MÉTHODOLOGIQUE." The lesson — how you size a position — should not depend on a signal that fires about one session in eight.
Broker margin model (CFD) (simulation.margin, disabled by default): with it enabled, the block
gains two extra frozen lines —
exposition = taille x prix x taux = 53,60 x 56,2200 x 0,8098 = 2440,45 CHF de notionnel
marge requise = exposition x 20% (modèle de marge broker CFD) = 488,09 CHF
Position size is capped so the required margin fits the account's free margin; when the cap
binds, the risk is recomputed from the real, reduced size (the R-multiple stays invariant to
sizing) and the "risque" line says so. A stop-out is simulated at 50 % of required margin —
often BEFORE the chosen stop. A simulation taking leverage no real account would finance teaches
nothing: see docs/incidents/2026-09-01-margin-model.md.
⚠️ ALERTE MARCHÉS
▼ Semi-cond. (SOX): 12 546,42 USD (-3,24%)
▲ VIX (volatilite): 16,27 USD (+8,25%)
▲ Petrole WTI: 74,48 USD (+4,30%)
Contexte :
• US strikes targets in Iran after Hormuz attack (Guardian)
• TSMC reports 68% surge in June revenue (CNBC Markets)
📐 L'ordre, si vous vouliez le passer
[ same fully-worked order block as above, on the instrument that triggered the alert ]
⚠️ Pédagogique et hypothétique — PAS un conseil financier ni un signal.
This is the part that matters. Nothing below is written by a language model.
| Line | How it is computed | Source |
|---|---|---|
| Quote + % change | Yahoo chart API. chg = price / ref − 1, where ref is the last close strictly before the current session — not simply "yesterday's row", which breaks around holidays |
sources/yahoo.py |
| "Depuis le dernier point" | Diff against the levels stored by the previous digest (kv_state.last_digest) |
render/digest.py |
| VIX reading | Level → label: <14 complacency, <18 calm, <25 caution, <32 stress, else panic |
derive.py |
| VIX term structure | VIX / VIX3M. Below 1 = contango (calm); above 1 = backwardation (acute stress) |
derive.py |
| Yield curve | US10Y − US3M. Negative = inverted (historical recession signal) |
derive.py |
| Equity composite | Mean of the daily % changes of the tracked indices | derive.py |
| Regime label | A 4-rule heuristic, in order: stagflation if composite < −0.3 and (oil > +1 or gold > +0.5) and rates > 0 · reflation if composite > +0.3 and rates > 0 and copper > 0 · risk-off if composite < −0.2 and (VIX > 0 or gold > 0) · risk-on if composite > +0.2 and VIX < 0 · else neutral. It is a heuristic, and message 2 is allowed to contradict it (see regime conflict) | derive.py |
| Copper/gold, 5 sessions | (C/G today ÷ C/G 5 sessions ago − 1) × 100 — a classic cyclical risk-appetite proxy |
derive.py |
| 10-day correlations | Pearson on daily returns, equities vs Gold / Dollar / VIX | derive.py |
| Sector rotation | Top 3 / bottom 3 sector ETFs by daily % change | derive.py |
| Positioning (COT) | CFTC weekly Commitments of Traders — speculators' net position | sources/cftc_cot.py |
| Economic calendar | FRED release dates (CPI, NFP, PCE, GDP, PPI, retail, JOLTS, FOMC) | sources/fred_calendar.py |
| Geopolitics | GDELT (Goldstein conflict scale −10…+10, article counts) + a self-hosted OSINT feed | sources/* |
| News + links | RSS. Links are appended by the code as message footers — the model never sees a URL and therefore cannot invent one | render/common.py |
| The prose | An LLM narrates from the computed snapshot. It may phrase, never compute | render/digest.py |
All of them share three guarantees: they read only quotes from the current session (see freshness
below), they never look at data after asof (so the whole thing is back-testable), and they publish
every check that ran — including the ones that did not fire, on the "Contrôles effectués" line. You
see the negative space, not just the hits.
1. Divergence — a beta residual, z-scored against its own history
β = cov(returns_target, returns_driver) / var(returns_driver) over 60 sessions
expected = β × driver_change_today
residual = target_change_today − expected
z = (residual − mean(residuals)) / stdev(residuals) sample stdev
fires if |z| ≥ 2.0
Auto-calibrated per pair: a pair that is normally noisy needs a bigger residual to be surprising.
Watched pairs: gold miners vs gold, energy vs Brent, semiconductors vs NASDAQ, banks vs 10-year yields,
bitcoin vs NASDAQ. → signals.py:_divergences
2. Extreme — an event, not a state
Percentile rank of today's price among its last 250 closes. It fires only on entry into the tail
(≤5th or ≥95th percentile, with the previous session inside the band), and never against an
established trend (20-session momentum: mean of the last 20 closes vs the 20 before). Copper sat
between the 96th and 99th percentile for six weeks while rising; a naive detector shorted it eight
times. → signals.py:_extremes
3. Correlation break — with a confidence interval, because a correlation on few points means nothing
r₆₀, r₂₅₀ = Pearson on daily returns
CI₉₅(r₆₀) = tanh( artanh(r₆₀) ± 1.96 / √(n − 3) ) Fisher z-transform
fires when CI₉₅(r₆₀) EXCLUDES r₂₅₀ — the relationship genuinely changed
→ signals.py:_correlation_break, stats.py:pearson
4. Regime conflict — reports tension when at least two corroborating signals dissent from the
regime label above. It reports; it never overwrites the label. → signals.py:_regime_conflict
5. Shock — a price move corroborated by a story
σ = stdev of DAILY RETURNS over 20 sessions (sample stdev)
shock = |change today| / σ
fires if shock ≥ 2.0σ AND |change| ≥ 2 % AND corroborated by news/geopolitics
Measured in σ, not in ATR. ATR measures the intraday range and is structurally larger: for crude
oil, ATR(20) was 5.02 % of price while σ was 2.84 % — judging a +4.33 % close-to-close move
against ATR declared it insignificant. Corroboration is a whole-word match against a per-instrument
keyword list, over news headlines, GDELT titles and militarily-active geographic zones. ("ble" is
inside "problem"; a substring match corroborates anything.) → signals.py:_shocks
Freshness — the quiet killer
Every quote carries the UTC date of the session it was quoted in. At 05:30 UTC a US ETF still
carries Friday's close: detecting on it re-analyses a session that was already analysed — and then
poisons the 3-day cooldown, so the real signal that evening is suppressed. Stale instruments are
skipped; their findings are replayed from the last session they actually traded in, dated as such
("constat de la séance du 2026-07-10"), never recomputed. No trade is ever opened on a stale
price. → signals.py:_fresh, sources/yahoo.py
Cooldown — a finding is not repeated for 3 days unless its strength grows by ≥ 0.25. Without it the same divergence is re-announced at 07:30, 17:45 and 22:15, then every day.
Identical maths in the digest and in the alert (one shared code path, render/common.py:order_calc).
ATR = Wilder's Average True Range over 20 sessions
TR = max(H−L, |H−C_prev|, |L−C_prev|)
stop = entry − side × 1 × ATR
target = entry + side × 2 × ATR
risk_chf = account × risk % → 5000 × 1 % = 50 CHF
size = risk_chf / (1 × ATR × FX_rate) → the only line that answers "HOW MUCH?"
target_chf = risk_chf × (2 / 1) → R:R 2:1
With the broker margin model enabled (simulation.margin, off by default):
notional_chf = size × entry × FX_rate
margin_chf = notional_chf × margin_rate(asset class) → size is CAPPED so margin ≤ free margin
(cap ⇒ risk_chf is recomputed from the real size: R stays invariant to sizing)
stop-out : exit at the price where equity_at_open + unrealized = stop_out_level × margin_chf
(FX frozen at open — documented approximation), checked before the ATR stop
Levels are set in multiples of ATR, never in round numbers: a stop tighter than the instrument's own daily noise gets hit by that noise alone, independently of any signal.
Resolution — daily high/low against stop and target, stop checked first (pessimistic when both are touched on the same day), otherwise the trade times out after 10 trading sessions — counted from actual price bars, not calendar days, so a weekend does not consume the horizon.
P&L, FX-aware at both ends:
gross = size × (close × FX_close − entry × FX_open)
pnl = gross if long else −gross
R = pnl / risk_chf
A USD instrument can rise and still lose money in CHF if the dollar falls. The bot reports the CHF
result, not the comfortable one. → levels.py, sim.py
Tracked vs pedagogical. A tracked trade (🎲) is scored on a public scoreboard; at most one is open at a time. The pedagogical order (📐) is never tracked and never scored — it exists so the reader sees the method on days when no signal fires. A shock never opens a tracked trade: back-tested without its news filter, following shocks turns the best configuration from +13.46 R into −1.14 R. It is shown, not bet on.
On the numbers above. The direction, thresholds and R:R are marked
UNVALIDATEDin the code and they mean it: they rest on ~29 closed back-test trades. That is not statistical significance, it is a hypothesis. The scoreboard exists precisely so reality — not the author — settles it.
Each digest stores its bias. The next one scores it: equity_realisee = the mean index move since,
then verdict = vérifié if the direction was right, invalidé if not (verify.py:_verdict). The
result is persisted, and a rolling hit rate is published once there are enough samples. A bot that
never publishes its misses is a marketing channel.
Per-kind thresholds (index 1.5 % · sector 2 % · commodity 3 % · rate 4 % · crypto 5 % · VIX 8 % · FX
1 %). After alerting at |chg| = a, it re-alerts only if |chg| ≥ a + max(1.5, threshold × 0.7) —
so a slow grind does not spam the channel. Alerts are entirely deterministic: no model, no API
call, nothing to hallucinate. → render/deterministic.py, render/alert.py
The interesting engineering is not the maths — it is what stops the maths from being quietly corrupted.
| Guard | What it prevents | Where |
|---|---|---|
| Compute-then-narrate | A model asserting a relationship nobody computed | everywhere |
| Provenance guard | Any number in the model's prose that the code did not produce → the rendering is rejected, and the deterministic template is sent instead | render/common.py:check_provenance |
| Frozen calculation block | The order maths must be reproduced verbatim. The provenance guard tolerates small integers (for "2 of the 4 signals") — so a model could rewrite R:R 2,0:1 into R:R 8,0:1: prices and size correct, but an order that looks four times more attractive |
render/common.py:check_frozen |
| Required headings | The model deleting the section that carries the order | render/common.py:check_headings |
| Broker margin model (CFD) | Displaying as financeable an order no real CFD account would finance — the 📐 block carries exposure and required margin, size is capped by free margin, stop-out is simulated | levels.py, sim.py |
| Freshness gate | Analysing a session that was already analysed, on frozen quotes | signals.py:_fresh |
| Import firewall | Any non-stdlib import, anywhere — an AST scan, not a grep | tests/test_no_deps.py |
| Leak guard | Private infrastructure (hosts, IPs, secrets shapes) reaching a shipped file | tests/test_no_private_infra.py |
| Atomic lock + dedup + circuit breaker | Overlapping cron runs, duplicate digests, hammering a failing API | lock.py, breaker.py |
The disclaimer is appended by the code, never left to the model — a model truncates or paraphrases it, and a truncation would drop the legal notice.
| Source | Data | Link |
|---|---|---|
| Yahoo Finance | quotes + OHLC history (indices, sectors, FX, crypto, commodities, rates, VIX) | https://finance.yahoo.com |
| GDELT | filtered geopolitical event stream (Goldstein scale, tone, article counts) | https://www.gdeltproject.org |
| CFTC COT | speculators' net positioning (weekly) | https://www.cftc.gov/MarketReports/CommitmentsofTraders/index.htm |
| FRED (St. Louis Fed) | macro series + economic-release calendar | https://fred.stlouisfed.org |
| EIA | US oil & gas inventories (a WTI/Brent driver) | https://www.eia.gov |
| CoinGecko | BTC/ETH dominance, crypto market-cap change | https://www.coingecko.com/en/api |
| alternative.me | crypto Fear & Greed index | https://alternative.me/crypto/fear-and-greed-index/ |
| Manifold Markets | market-implied macro/geopolitical probabilities | https://manifold.markets |
| Telegram Bot API | delivery | https://core.telegram.org/bots/api |
| Anthropic Claude | narration — default backend, driven through the CLI on a subscription token, not a metered API key | https://www.anthropic.com |
| Google Gemini | narration fallback only (metered API) | https://ai.google.dev |
Learn the concepts — every "word of the day" links out: ATR · Beta · Contango · Correlation · Credit spread · Percentile · Yield curve
cron → run.sh {digest|alert|pnl|prune} → cli.main (atomic lock)
│
├─ build_snapshot ──(sources/*)──► derive.compute ──► Snapshot (dataclasses) ──► SQLite
│
├─ DIGEST
│ ├─ render/digest.py → message 1 (briefing) LLM narrates the computed snapshot
│ ├─ signals.detect → the five detectors pure Python, back-testable (asof)
│ ├─ verify.reconcile → yesterday's verdict persisted, scored
│ ├─ sim.select_trade → tracked paper trade at most one open
│ ├─ sim.example_order → pedagogical order (📐) never tracked, never scored
│ ├─ render/decrypt.py → message 2 (décryptage) deterministic template; LLM = varnish
│ └─ render/pnl.py → message 3 (P&L reminder) latent P&L per AI track; 100 % deterministic
│
├─ ALERT
│ └─ render/alert.py → thresholds + re-step + order 100 % deterministic
│
└─ PNL (intraday follow-up)
└─ render/pnl.py → refreshed P&L reminder targeted quote refresh + stop/target scan
marketbot/: cli schema store http derive stats signals verify levels sim
glossary telegram llm llm_claude lock breaker config · render/ (digest decrypt
alert deterministic common pnl report) · sources/ (yahoo news_rss sentiment coingecko cftc_cot
manifold fred fred_calendar eia …). See DESIGN.md for the decisions.
git clone https://github.com/YellowBeanie/marketbot.git && cd marketbot
cp config.example.json config.json # then edit — config.json is gitignored
python3 -m unittest discover -s tests # 248 tests, no network, no pip
python3 run.py digest --no-send # build the digest and print it, without postingThere is nothing to install. marketbot is standard-library only, and a test fails the build if that ever stops being true.
Configure (config.json): instruments (name / Yahoo symbol / kind), sources.*.enabled,
thresholds, signals (detector windows and thresholds), simulation (account, risk %, ATR
multiples, horizon), telegram.markets_chat, models.
Secrets (environment, never in the repo):
export TELEGRAM_BOT_TOKEN=... # required — to post
export CLAUDE_CODE_OAUTH_TOKEN=... # recommended — Claude CLI SUBSCRIPTION auth, no API billing
# generate once: claude setup-token
export GEMINI_API_KEY=... # optional — fallback only, if the Claude CLI is unavailable
export FRED_KEY=... EIA_KEY=... # optional — extra sourcesNeither LLM key is mandatory: without any model, the deterministic renderer still posts the digest and the alerts. See It runs on a subscription.
CLI
python3 run.py ingest # fetch sources → snapshot → SQLite, no send
python3 run.py show # print the latest stored snapshot
python3 run.py digest # build + post the digest (briefing, décryptage, P&L reminder)
python3 run.py alert # post an alert if the market moved notably
python3 run.py pnl # intraday follow-up: refresh held quotes, scan stops/targets, repost the P&L
python3 run.py report # weekly track review: week/cumulative stats, drawdown, equity sparkline
python3 run.py selfcheck # read-only health check (secrets, today's sends, frozen bars) — exit 1 on issues
python3 run.py prune # weekly maintenance (bound the DB + VACUUM)Note — you cannot prune a Telegram channel's history from a bot. The Bot API refuses to delete any message older than 48 hours, and the
can_delete_messagesadministrator right does not lift that limit (measured: a 12-hour-old message deletes fine, a 12-day-old one returnsmessage can't be deleted— even one the bot sent itself). Any retention feature built on the Bot API is therefore a dead end; history has to be cleared from a Telegram client, which speaks MTProto.
Flags: --no-send (print, never post — it mutates no state, so it is safe against production) ·
--no-lock (bypass the cron lock for a manual run).
Contributions are welcome — issues, data sources, glossary terms, and an English translation of the
user-facing strings are all good first steps. Read CONTRIBUTING.md: the workflow,
the tests, the stdlib-only rule, and the DCO sign-off (git commit -s) we require instead of a
CLA. By participating you agree to the Code of Conduct. Security issues go
privately through SECURITY.md.
marketbot is an educational project. Its messages are not financial advice and not trading signals. Every order it displays is hypothetical, sized on a fictional account, and the pedagogical ones are never even tracked. Markets can lose you money; leverage amplifies both gains and losses. Nothing here is a recommendation to buy or sell anything.
AGPL-3.0 — see LICENSE. Copyright © 2026 YellowBeanie. If you run a modified version as a network service, the AGPL requires you to publish your changes.