YellowBeanie/marketbot

Telegram OSINT + finance market-analysis bot — educational, stdlib-only, AGPL-3.0

★ 1Forks 0PythonGitHub ↗Compare
agpleducationalfinancemarket-analysisosintpythonstdlibtelegram-bot

README

marketbot

A Telegram bot that turns market, macro and geopolitical data into a pedagogical briefing — and shows the maths behind every single number.

License: AGPL v3 CI dependencies: stdlib only python: 3.11+

🇬🇧 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.


What it is

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.


It runs on a subscription, not on a metered API

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_KEY is 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-prompt replaces 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 augmented PATH — cron's PATH does not include ~/.local/bin.

What you actually receive

1. The briefing — facts, sourced and linked

📈 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

2. The décryptage — what the facts imply, and how you would size an order

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

3. The alert — a notable move, with its order. No model involved.

⚠️ 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.

How every number is computed

This is the part that matters. Nothing below is written by a language model.

The briefing

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

The décryptage — the five detectors

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.

The order — how a number becomes a position

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 UNVALIDATED in 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.

Verification — the bot grades itself

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.

Alerts

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 guardrails

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.


Sources

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


Architecture

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.


Quickstart

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 posting

There 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 sources

Neither 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_messages administrator right does not lift that limit (measured: a 12-hour-old message deletes fine, a 12-day-old one returns message 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).


Contributing

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.

Disclaimer

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.

License

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.

Contributors

YellowBeanie

Issues