Automated stock monitoring system built with n8n + Warren (OpenClaw agent) + a Python anomaly-detection layer (market_intelligence/).
Deterministic EOD anomaly detection, one Telegram channel:
| Layer | What | When | LLM? |
|---|---|---|---|
| B — Anomaly detection (EOD) | Price/volume anomaly scan → beta gate → dedup → deterministic digest | 21:30 UTC, Mon–Fri (after US close) | Zero LLM — the digest is pure Python (signals + hysteresis reason); Warren is on-demand only (point sur TICKER) |
Layer B EOD Schedule (21:30 UTC, Mon–Fri — DST-safe, always ≥ 30 min after US close)
│
▼
python3 -m market_intelligence.eod_orchestrator --history-days 280
│ S0 fetch EOD OHLCV (yfinance, Twelve Data fallback) + registry/quarantine
│ S1 anomaly signals (z-score MAD, RVOL, gap, ATR, 52-week breakout)
│ S2 beta gate (market-model regression vs IWM + sector ETF)
│ S3 candidate alerts (calm |z|>2.0 / speculative |z|>2.5)
│ S4 short interest → squeeze-prone flag
│ S5 dedup (hysteresis latch per ticker)
│ Layer C tension scan (registry + watchlist tickers, no LLM)
│ → tension.jsonl journal + ⚡ digest block on new episodes
│ Deterministic digest (zero LLM) — canned per-survivor prose keyed by
│ the dedup fire_reason with the anomaly numbers slotted in; each
│ line tagged with the ticker's origin (💼 portfolio / 👀 watchlist)
│ and opened on the raw close + intraday read + z gloss; points
│ the reader to on-demand Warren (« point sur TICKER »)
▼
JSON result { survivor_count, should_send, digest, data_issues,
candidates_detail, dry_run, run_id, pending_state_path }
│
├─ one line appended to runtime/market_intelligence/runs.jsonl
│ (every candidate with its exact non-alert reason — the run explains itself)
│
▼ if survivors
One Telegram digest (HTML-safe, reuses Aggregate / Split / Send Telegram nodes)
│
▼ after confirmed delivery (or immediately when no survivor)
Commit Dedup State — the pending dedup state is only promoted once Telegram
delivery is confirmed (two-phase commit). A failed send leaves the state
untouched and the alert re-fires at the next run.
No alert survives → nothing is sent (but the run is journaled, the Friday heartbeat proves the pipeline is alive, and an external watchdog would have alerted if the run had not happened at all).
On speculative small caps, price often moves before the news becomes public (rumor, accumulation, squeeze). A pipeline that only reads news therefore arrives late. Layer B does not predict anything: it detects that an unusual move has just happened, then surfaces it for on-demand investigation (« point sur TICKER » to Warren). It is an attention detector, not a crystal ball — and that claim is now measured, not asserted: a no-look-ahead ablation over 2022-2026 (docs/RESULTS.md) shows directional hit rates of 41-49% (≈ coin flip) for every variant of the pipeline. Its measured value is selectivity (30 alerts/month vs 79 for a naive >5%-move rule, −62%), not direction. Detecting before the move is Layer C's job (below).
A stock moving 8% in one day is huge for Xylem (a quiet water company) and ordinary for Rigetti (a volatile quantum stock). A fixed percentage threshold would therefore make no sense. Instead, the day's move is compared with the ticker's own behavior over the last 60 days:
z-score = "how many times larger than usual?" z = 1 → normal day. z = 2 → big, rare day. z = 3 → exceptional.
The z-score "normalizes" each ticker by its own volatility.
Robustness detail: "usual behavior" is measured with the median (MAD) rather than the mean, so that a few past extreme sessions do not distort the reference.
If the whole small-cap market drops 3%, your speculative ticker may drop 5% without any company-specific news. Alerting on that would be noise. The image: the tide vs. the wave. We want to detect the wave (the ticker's own move), not the tide (the whole market's move).
In practice, the system learns the ticker's market sensitivity over 60 days (its "beta": when the IWM small-cap index moves 1%, this ticker moves 2% on average). On the day of the move, it calculates the expected move given what the market did, subtracts it from the real move, and keeps the residual: the part of the move the market does not explain. The z-score from step 1 is applied to this residual.
For strongly thematic stocks (nuclear, quantum, water...), the system also removes the part explained by the sector ETF (NUKZ for SMR/OKLO, QTUM for RGTI...), but only if the ticker actually follows that ETF (correlation > 0.35); otherwise it would only introduce noise.
A price alert = |residual z| > 2 (calmer names: XYL, MMED) or > 2.5 (speculative cluster: RGTI, BBAI, OKLO...). One global setting; per-ticker normalization does the rest.
- RVOL (relative volume): today's volume ÷ 20-day average volume. Volume ×3 with no news = someone may know something, or a squeeze may be starting. This is the #1 early signal on small caps.
- ATR expansion: the day's range (high-low) exceeds 1.5× the usual range → "the ticker is waking up", even if it closes flat.
- 52-week breakout: new yearly high or low.
- Combination: alert if price is abnormal, OR if abnormal volume + a second signal confirms it.
A stock that takes off often remains volatile for several days. Without a guardrail, the same alert would arrive every evening. The mechanism works like a thermostat: once the alert fires, the ticker is "locked" and does not re-alert until it has calmed down again (|z| < 1 for at least one day), unless something genuinely new happens: direction reversal, a new signal type, or a clear escalation. Safety valve: after about 10 trading days, the lock expires.
Surviving alerts cost zero LLM. Each survivor is rendered with canned prose
keyed by the dedup fire_reason (initial / escalation / new signal type /
direction reversal) with the anomaly numbers slotted in, plus a squeeze-prone
flag when relevant.
Every line carries the ticker's origin — read from portfolio.json /
watchlist.json, portfolio winning when a ticker is in both, registre seul
when it is in the registry but in neither list (a referential inconsistency
worth seeing). Fail-soft: an unreadable list drops the tags, never the run.
Each alert's prose opens on the day's raw close, then reads the session by three fixed rules on the opening gap (move already there at the open / built in session / intraday reversal, with a "cassure ratée" when a 52-week breakout was sold off). The z-residual keeps its exact figure and gains a plain-language gloss; each tension signal gets a canned one too. Still zero LLM: every sentence is a template triggered by an existing field.
Since Epic 10 S3 each block also says where the selection thesis stands on
that ticker — the screener's own judgment context (cohort on the watchlist
entry: entry date and price, day n/63, return since entry, checkpoint status),
omitted field by field when absent, and entirely for a ticker outside any cohort.
A trajectory note follows, in three states, never two: a compression episode
recorded before this alert (date + lead), none over a journal long enough to
conclude (MIN_TENSION_HISTORY_DAYS = 20 trading days, the tension measurement
window), or "too short to tell" — a ticker that joined the cohort last week has
no history, and silence there must not read as a negative signal. The note is
descriptive: an earlier episode is never presented as the cause of the anomaly.
Section titles say what the section means, not which pipeline stage produced it: "Mouvements inhabituels" (the move HAS HAPPENED, the direction is a fact about the session, never a forecast) and "Titres qui se compriment" (direction unknown, hypothesis under measurement). "Layer B", "Layer C" and "survivant" appear nowhere in the message.
1. HIMS (💼 portefeuille) — baisse ↓ [escalade]
Escalade : HIMS était déjà verrouillé (il avait déclenché à −2,4)…
Concrètement : clôture −9,8 % aujourd'hui, alors que le titre avait OUVERT en
hausse (+2,2 %) et touché un plus-haut de 52 semaines en séance — il s'est
retourné en cours de journée (cassure ratée : l'élan du matin a été vendu).
🔎 Suivi de compression trop court (6 jour(s) suivis sur 20) — impossible de
dire s'il y a eu compression.
2. BBAI (👀 watchlist) — hausse ↑ [première alerte]
Concrètement : clôture +7,2 % aujourd'hui, après une ouverture déjà en hausse
(gap +6,4 %). … Son z-résiduel atteint +2,8 (seuil 2,5) — le titre bouge
nettement plus que son comportement habituel : son mouvement propre, une fois
retirée la part expliquée par le marché, fait environ 3× sa journée typique.
🎯 Retenu le 17/07 à 4,12, jour 27/63 (36 restants), +8,0 % depuis l'entrée,
au-dessus de son repère de première semaine.
🔎 Compression repérée le 12/06, 35 jours avant cette alerte.
ASTS (registre seul): squeeze (bw pctl 8%) ← ⚡ bloc « titres qui se compriment »
↳ volatilité comprimée dans le pire décile de son année — un mouvement se
prépare, direction inconnue
The digest points the reader to Warren for a deeper look on demand — « point sur TICKER » — which is when insider buying/selling (SEC EDGAR Form 4), product and sector news, halt (FINRA) and SSR (Nasdaq) status and the ticker's news memory are pulled together. Warren is allowed to conclude "no identifiable catalyst — flow/technical/squeeze likely" rather than make things up. The dead n8n→OpenClaw HTTP bridge that used to run this per-alert automatically was removed in Epic 6 (deterministic digest is now the only alert path).
- It does not predict direction (a volume spike says "look", not "it goes up"). Measured: J+5 hit rate 44% vs 49% for the naive baseline (docs/RESULTS.md).
- It does not detect before the move: it scans close bars at 21:30 UTC, so the move is hours old by design. That is Layer C's job.
- It does not trade. A high false-positive rate is accepted: this is an attention tool, strictly better than a daily news scan, not a robot.
Configuration: thresholds in market_intelligence/data/alert_thresholds.json,
sector mapping in data/sector_factors.json, hysteresis in
data/dedup_thresholds.json, squeeze in data/short_interest_thresholds.json.
The theory: explosions on small caps are preceded by silent accumulation visible on close bars — volatility compression and volume without price. Layer C scores that tension every evening and alerts on the first day a ticker enters a tension state, typically days before any move (median lead in backtest: ~13 trading days).
| Signal | Definition | Threshold |
|---|---|---|
| SQUEEZE | 20d Bollinger bandwidth percentile within the trailing year | ≤ 10% |
| QUIET (silent accumulation) | 5d mean relative volume, with a flat 5d cumulative return | rvol5 ≥ 2 and |cum5| < 3% |
| TENSION | SQUEEZE or QUIET | — |
No direction is predicted (compression predicts expansion, not sign) and no LLM is involved: the ⚡ digest block is deterministic.
Coverage — portfolio and watchlist. Layer C scans every registry ticker
(166 as of July 2026: the full VPS portfolio + watchlist, onboarded in PR #53)
plus any watchlist ticker not yet in the registry — the tension tier:
OHLCV fetched directly in one batched call, no registry entry, classification
or sector-ETF mapping required. A ticker added via /modifywatchlist is
therefore scanned by Layer C the same evening, before its registry
onboarding; registry_check reports it as info, not blocking. Full Layer B
(beta gate + candidate alerting) requires the registry entry.
Honesty section. The phase-0 backtest (2022-2026, docs/TENSION.md)
found lift 1.5-1.66 for P(move > 2× the ATR-expected 20d move) after a tension
episode — but the signal is regime-unstable: ~1.1 in 2022-2024, ~1.9 in
2024-2026. Alerts are live since 2026-07-10 (owner's decision) while the live
measurement runs in parallel: every ticker-day is journaled
(runtime/market_intelligence/tension.jsonl), episode outcomes are measured at
J+20 (tension_outcomes, systemd timer 22:35 UTC), and the reading benchmark —
informative, not binding — is lift ≥ 1.5 over ≥ 50 measured episodes.
python3 -m market_intelligence.tension_outcomes --report shows the running
tally.
market_intelligence/v5_bridge.py makes every ticker that entered a washout
cohort in smallcaps-screener
watched by Layer C for its judgment window, then drops it out again. It carries
attention, never a verdict: both signals (tension here, v5 cohort there) are
in forward validation.
- Source — a snapshot file,
runtime/screener/latest.json, pushed by the workstation whenever a scan lands (deploy/screener-push.*): the screener runs at home behind a router, so the VPS never could call it. The pushed payload is the/api/scanresponse, which embeds the v5 tracking journal (one row per window × ticker). The union of the 7/14/21-day windows is deduplicated per ticker, longestdays_heldwinning. The HTTP source survives for a run made on the workstation (run(api_url=…));⚠️ never point it at a public URL: the API has no authentication and serves unversioned edge values. - Only forward in time — the
scanned_atof the applied snapshot is memorised inwatchlist.json(v5_scanned_at). A snapshot that is not strictly newer is refused and logged, never written: age breaks nothing, but an old snapshot replayed would remove every ticker qualified since. Beyond 3 trading days without a fresh snapshot the EOD watchdog alerts on Telegram. - Reconciliation, not an event stream — the target state is derived from the
journal on every run: tracked and
days_held < 63→ in the watchlist;days_held >= 63(the v5 judgment horizon) or gone from the journal → out. Idempotent, so a missed run costs nothing. - The referential follows the cohort (Epic 10 S2) — what the reconciliation
adds is onboarded (
registry.jsonentry,speculativeclassification,single_factorcoverage) and what it removes is offboarded, reusingticker_onboard.pyas it is. Nothing is offboarded while the symbol still sits in a runtime list, so portfolio tickers are structurally protected — the same guard the Telegram remove flow applies. Validating a symbol is a network call: a failure is a logged skip for that symbol alone, counted in the run summary (onboarded/offboarded/skipped/failed), never an exception that sinks the ones after it. Before this, 86 % of the alert universe sat outside the cohort: 165 registry tickers for 23 live ones. - Provenance
source: "smallcaps-v5"— the bridge removes only its own entries. A ticker added via Telegram is untouchable (safety rule #1). - Cap of 150 bridged tickers, with an explicit log line listing the excluded ones — never a silent truncation.
- Fail-soft throughout — API down, non-200, malformed payload → logged no-op. Empty tracking while bridged tickers are still under horizon → no-op plus a warning: that is an anomaly, never a purge.
- Cadence —
v5-bridge.timer, 20:45 UTC Mon–Fri: after the smallcaps scan of the day, before the 21:30 UTC EOD run, so a ticker qualified today is scanned by Layer C the same evening.Persistent=true, so a run missed while the VPS was down fires at boot — harmless, the reconciliation is idempotent. Installed and enabled bydeploy/remote.shlike the other timers; state reported asSTATUS_V5_TIMER. - Not committed to git —
watchlist.jsonis gitignored (only the.examplefiles are versioned), and since Epic 10 S4 the referential state lives under the gitignoredruntime/referential/too. The bridge's writes therefore live on the VPS only, exactly like a Telegram/modifywatchlistadd. Nothing to wire: no runtime list has ever been versioned. - Disabling it —
sudo systemctl disable --now v5-bridge.timer; the watchlist keeps whatever it holds (bridged entries stop expiring, which is inert). Removing them by hand means dropping the entries taggedsmallcaps-v5.
Run it manually: python3 -m market_intelligence.v5_bridge (prints the run
summary as JSON), or sudo systemctl start v5-bridge.service on the VPS.
First run adds ~80 tickers, not ~20. The tracking journal carries every cohort member still under horizon, not just the day's entries — measured at 82 on 2026-08-04. Well under the cap of 150, but the watchlist jumps from ~152 to ~234 symbols: check the EOD run duration afterwards (baseline: 2 min 05 for 181 symbols).
After post-mortem PM-0001 (19 days of silence: the n8n workflow version was
never published and executeCommand was banned by default in n8n 2.20), five
epics turned the demo into a system:
- Transactional dedup state — any non-official run is dry-run by default
(
--dry-run/ANOMALY_DEDUP_READONLY=1, zero side effects); the official run writes a candidate state (dedup_state.pending.json+run_id) that n8n only promotes after confirmed Telegram delivery. Failed send → the alert re-fires. Admin tool:python3 -m market_intelligence.dedup_admin show|reset|commit. - Observability — append-only run journal (
runs.jsonl, one line per run with the exact reason each candidate did not alert, plusdigest_charsandchunk_count— the rendered digest length and the number of Telegram messages it produces, written on every run including dry-runs and zero-survivor nights), an EOD watchdog outside n8n (systemd timer, 22:15 UTC — if n8n dies, the watchdog survives, and alerts through the raw Telegram API), and a Friday heartbeat so weekly silence is bounded by a positive signal. Zero LLM in the whole chain. - Safe delivery — every value sent to Telegram is HTML-escaped
producer-side (
parse_mode=HTML), the 4,000-char splitter never cuts a tag in half nor strands a survivor headline away from its prose (market_intelligence/telegram_split.py, the single implementation — the n8n Split nodes only relay the chunks Python produced), andSend Telegramretries ×3 with the final failure kept visible (nocontinueOnFail). - Unified ticker referential —
python3 -m market_intelligence.registry_checkis blocking in CI and at deploy time: a portfolio ticker can no longer be silently invisible to detection. A watchlist ticker not yet in the registry is still scanned by the tension tier (Layer C) the same evening and is reported as info, not blocking — never invisible, never a deploy failure. New registry tickers are onboarded with safe defaults (speculativeclassification,single_factor_symbols, symbol validated before any write); the sector-ETF choice remains an explicit human decision in PR. - Track record — J+1/J+5/J+20 outcomes are measured for sent alerts and
for gated candidates (
outcome_tracker, systemd timer 22:30 UTC), a monthly Telegram report aggregates them (with an honest "sample too small" guard), and a no-look-ahead backtest (python3 -m market_intelligence.backtest --start … --end …) replays the deterministic pipeline over years of history to calibrate thresholds.
| Skill | Trigger | What |
|---|---|---|
| tickerbrief | brief TICKER, point sur TICKER, actu TICKER |
On-demand brief: today's news + ticker memory + EOD anomaly state + sector. Read-only. |
| modifyportfolio | /modifyportfolio |
Add/remove tickers from portfolio.json interactively |
| modifywatchlist | /modifywatchlist |
Add/remove tickers from watchlist.json interactively |
tickerbrief assembles: memory/tickers/SYMBOL.md, fresh web search news, dedup_state.json anomaly state, sector_factors.json. Read-only — never writes files. Returns a signal-first Telegram reply (anomaly status first, then news, then memory). For tickers not in portfolio/watchlist, returns raw web search only.
modifyportfolio / modifywatchlist — send /modifyportfolio (or /modifywatchlist) to Warren in Telegram:
- Warren replies with [➕ Add a ticker] [➖ Remove a ticker] inline buttons.
- Add → type the symbol(s), e.g.
NVDA, MSFT→ file updated withaddeddate + ✅ confirmation. - Remove → current tickers shown as toggle buttons → select → ✅ Validate.
Never hand-edit portfolio.json / watchlist.json — the flow goes through agents/warren/manage_tickers.py (atomic write, dedupe, date stamp).
- EOD anomaly trigger (B) — deterministic detection, one Telegram channel
- Zero LLM in the detection path — Layer B is pure Python/statistics until an alert survives
- Ticker news memory — Warren's anomaly research includes the ticker's news memory (
memory/tickers/) - Sector rotation signal —
sector_rotation.pycomputes sector ETF relative performance + IWM/SPY ratio (zero LLM) - Fear & Greed —
fear_greed.pyfetches CNN Fear & Greed index (best-effort, zero LLM) - On-demand ticker brief —
tickerbriefskill via Telegram: full ticker context without triggering a digest - Tickers as data —
portfolio.json/watchlist.jsonare the source of truth, editable via Telegram (modifyportfolio/modifywatchlistskills) - Symbol integrity — registry + quarantine (
market_intelligence/data/) so analysis never runs on a wrong ticker - Auto-split Telegram — messages split at 4,000 characters at paragraph boundaries, HTML-safe
- Transactional dedup state — dry-run by default, two-phase commit after confirmed delivery
- Run journal + external watchdog + weekly heartbeat — silence is informative, not ambiguous
- Referential consistency enforced —
registry_checkblocking in CI and at deploy - Self-measured signal — J+1/J+5/J+20 outcomes, monthly report, no-look-ahead backtest
- systemd managed — n8n, OpenClaw gateway (on-demand Warren) + watchdog/outcome timers
- CI/CD — push to
main→ 428 tests → auto-deploy on the VPS via self-hosted runner (workflow version published, referential validated)
| Component | Role |
|---|---|
| n8n (self-hosted) | Scheduling, API calls, credential management, delivery |
| OpenClaw | Agent framework wrapping Claude for Warren |
| Warren (OpenClaw agent) | On-demand intelligence — ticker briefs ("point sur X"), Telegram ticker management |
| agents/warren/ | manage_tickers.py — add/remove tickers backing the Telegram skills |
| market_intelligence/ | Layer B — EOD fetch, anomaly signals, beta gate, dedup, EDGAR, short interest, orchestrator |
| market_intelligence/sector_rotation.py | Sector ETF relative performance + IWM/SPY ratio — zero LLM |
| market_intelligence/fear_greed.py | CNN Fear & Greed index fetch — zero LLM |
stock-tracker/
├── workflow.json # n8n workflow (Layer B EOD anomaly wiring)
├── portfolio.json # 8 portfolio tickers (source of truth)
├── watchlist.json # 8 watchlist tickers (source of truth)
├── requirements.txt # Python deps (requests, numpy, pandas, yfinance, pyarrow)
├── Makefile # Verification entrypoints — CI calls these targets, not the tools
├── .ruff.toml # Lint rules, target-version py310 (the VPS system python)
├── agents/warren/ # manage_tickers.py — ticker management backing the Telegram skills
├── market_intelligence/ # Layer B anomaly detection (S0–S5) + reliability & measurement tooling
│ ├── eod_orchestrator.py # Pipeline chief: dry-run flags, run journal, HTML-safe digest
│ ├── dedup_hysteresis.py # Hysteresis latch + suppression reasons + pending state (two-phase)
│ ├── dedup_admin.py # State admin CLI: show / reset / commit
│ ├── registry_check.py # Referential consistency validator (blocking in CI + deploy)
│ ├── ticker_onboard.py # Safe-default onboarding of a new ticker
│ ├── outcome_tracker.py # J+1/J+5/J+20 outcomes for alerts and gated candidates
│ ├── monthly_report.py # Monthly track-record Telegram report
│ ├── backtest.py # No-look-ahead backtest & threshold calibration harness
│ ├── weekly_heartbeat.py # Friday proof-of-life message (zero LLM)
│ ├── sector_rotation.py # Sector ETF rotation + IWM/SPY ratio (zero LLM)
│ ├── fear_greed.py # CNN Fear & Greed index (zero LLM)
│ ├── v5_bridge.py # smallcaps v5 cohorts → watchlist reconciliation (zero LLM)
│ ├── telegram_split.py # Telegram 4096-char split — sole implementation, n8n only relays
│ └── data/ # registry, quarantine, thresholds, sector factors
├── skills/ # OpenClaw skills sources
│ ├── tickerbrief/ # On-demand ticker brief skill spec (SKILL.md)
│ ├── modifyportfolio/ # Portfolio management via Telegram (with Layer B onboarding)
│ └── modifywatchlist/ # Watchlist management via Telegram (with Layer B onboarding)
├── tests/ # pytest suite — 428 tests (agents, market_intelligence, deploy, workflow wiring)
├── docs/ # project-structure, deployment, backtest guide, ticker schema
├── deploy/ # CI/CD: remote.sh, import_workflow.py (version publish),
│ # deploy_verdict.py (deploy pass/fail, testable off-VPS),
│ # watchdog_eod.py + systemd units/timers (watchdog, outcome tracker, v5 bridge)
└── .github/workflows/ # CI + auto-deploy + Notion sync
/home/warren/.openclaw/workspace-warren/ (on the VPS)
├── PROMPT.md / SOUL.md / IDENTITY.md / ... # Warren agent definition
├── ARCHITECTURE.md # Pipeline documentation
├── skills/
│ ├── tickerbrief/ # On-demand ticker brief skill
│ ├── modifyportfolio/ # Telegram portfolio management
│ └── modifywatchlist/ # Telegram watchlist management
└── memory/tickers/ # SYMBOL.md — last 3 raw news entries (read by tickerbrief)
- Ubuntu 22.04 VPS (2 GB RAM minimum)
- A dedicated system user for the app (recommended:
warren) - Your Anthropic API key, Telegram bot token
sudo useradd -m -s /bin/bash warren
sudo usermod -aG sudo warren # optional — remove after setup if desired# As warren user (or any user that will run n8n)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 22
node -v # v22.x.xOr system-wide via NodeSource:
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejssudo npm install -g n8n
n8n --versionOpenClaw is the agent framework that runs Warren. Install it globally:
sudo npm install -g openclaw
# or: npm install -g @openclaw/cli (check current package name)
openclaw --versionThen configure OpenClaw for the warren user:
sudo -u warren openclaw initThis creates /home/warren/.openclaw/ with the default config.
sudo mkdir -p /opt/apps/stock-tracker
sudo chown warren:warren /opt/apps/stock-tracker
sudo -u warren git clone https://github.com/patw47/stock-tracker.git /opt/apps/stock-tracker
cd /opt/apps/stock-tracker
sudo pip3 install --break-system-packages -r requirements.txtsudo -u warren cp .env.example .env
sudo -u warren nano .envFill in:
ANTHROPIC_API_KEY=sk-ant-...
TELEGRAM_TOKEN=123456789:ABC...
TELEGRAM_CHAT_ID=1234567890
TWELVE_DATA_API_KEY=... # Layer B EOD fallback data source
NODE_FUNCTION_ALLOW_BUILTIN=fs # lets n8n Code nodes read portfolio/watchlist.json
N8N_PORT=5680
N8N_USER_FOLDER=/opt/apps/stock-tracker/n8n-data
N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=admin
N8N_BASIC_AUTH_PASSWORD=your-password
GENERIC_TIMEZONE=Europe/Paris
N8N_DEFAULT_LOCALE=fr
N8N_ENCRYPTION_KEY=$(openssl rand -base64 32)Telegram: create a bot via @BotFather, get your chat ID via @userinfobot.
Copy the workspace files to the OpenClaw directory:
sudo cp -r /opt/apps/stock-tracker/workspace-warren \
/home/warren/.openclaw/workspace-warren
sudo chown -R warren:warren /home/warren/.openclaw/workspace-warrenCreate the memory directory:
sudo -u warren mkdir -p /home/warren/.openclaw/workspace-warren/memory/tickersRegister Warren as an OpenClaw agent:
sudo -u warren openclaw agent add warren \
--workspace /home/warren/.openclaw/workspace-warren \
--model claude-haiku-4-5-20251001Verify:
sudo -u warren openclaw agent list # warren should appearsudo -u warren openclaw config set anthropic.apiKey sk-ant-...
# or edit /home/warren/.openclaw/openclaw.json directlysudo nano /etc/systemd/system/stock-tracker.service[Unit]
Description=Stock Tracker — n8n
After=network.target
[Service]
Type=simple
User=warren
WorkingDirectory=/opt/apps/stock-tracker
EnvironmentFile=/opt/apps/stock-tracker/.env
ExecStart=/usr/bin/n8n start
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.targetsudo nano /etc/systemd/system/openclaw-warren.service[Unit]
Description=OpenClaw Warren Gateway
After=network.target
[Service]
Type=simple
User=warren
Environment=HOME=/home/warren
Environment=PATH=/usr/local/bin:/usr/bin:/bin
ExecStart=/usr/local/bin/openclaw gateway start --agent warren
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetEnable and start both:
sudo systemctl daemon-reload
sudo systemctl enable stock-tracker openclaw-warren
sudo systemctl start openclaw-warren
sleep 3
sudo systemctl start stock-trackerVerify:
sudo systemctl status stock-tracker openclaw-warrenBoth should show active (running).
sudo -u warren \
N8N_USER_FOLDER=/opt/apps/stock-tracker/n8n-data \
N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false \
n8n import:workflow --input=/opt/apps/stock-tracker/workflow.jsonActivate the workflow via SQLite (n8n import deactivates by default):
sqlite3 /opt/apps/stock-tracker/n8n-data/.n8n/database.sqlite \
"UPDATE workflow_entity SET active=1 WHERE id='veille-boursiere-001';"Restart n8n to pick up the change:
sudo systemctl restart stock-trackerAccess the UI via SSH tunnel (n8n binds to localhost only):
ssh -L 5680:localhost:5680 warren@your-vps-ip -NThen open http://localhost:5680 in your browser.
Login: the values you set in .env (N8N_BASIC_AUTH_USER / N8N_BASIC_AUTH_PASSWORD).
Go to Credentials and create:
| Name | Type | Settings |
|---|---|---|
| Header Auth account | HTTP Header Auth | Header: x-api-key · Value: your Anthropic key |
| Telegram account | Telegram API | Token from BotFather |
After creating credentials, check the node IDs in the workflow match. If they don't, open each affected node and reselect the credential from the dropdown.
Test the Layer B pipeline end-to-end (prints a JSON payload):
cd /opt/apps/stock-tracker && python3 -m market_intelligence.eod_orchestrator --history-days 280Test n8n is up:
curl -s http://localhost:5680/healthz
# Expected: {"status":"ok"}CI and the local machine run the same commands: .github/workflows/ci.yml
calls the make targets, never the tools directly.
make test # full offline pytest suite (428 tests)
make lint # ruff check . — rules in .ruff.toml, target-version py310
make validate-json # workflow.json + the two *.example.json stay parseable
make check-referential # registry vs portfolio/watchlist coherence (blocking)
make check-config-invariant # the configuration half has not drifted since Epic 10 S4
make test-deploy # deploy verdict logic, no VPS neededThe CI job reports its QA verdict to Notion from job.status, so a run whose
tests fail can no longer report a green verdict — whatever the other steps did.
Pushing to main auto-deploys to the VPS once CI is green. The deploy job runs on a
self-hosted GitHub Actions runner installed on the VPS (no SSH).
push main → CI green ──► .github/workflows/deploy.yml (runs-on: self-hosted)
│
├─ gate: "Notify PR merge conflicts" green on same commit
├─ git fetch (token-auth) + reset --hard origin/main
└─ deploy/remote.sh (local on the VPS):
pip install -r requirements.txt
stop stock-tracker (release sqlite lock + free port 5679)
import_workflow.py (upsert workflow into sqlite)
décommission du pont Warren mort (disable --now warren-server)
restart openclaw-warren (gateway on-demand)
n8n execute --id (validation run, as warren, n8n stopped)
start stock-tracker
healthcheck: services + n8n :5680/healthz
+ the 4 systemd timers must all be `active`
deploy_verdict.py → ok / fail (fail-closed:
no verdict at all counts as fail)
→ Telegram status message (success / failure, one
indicator per service and timer)
See docs/deployement.md for the full rationale (self-hosted runner, custom sqlite
importer, validation-run constraints, user roles).
portfolio.json and watchlist.json are the source of truth — the Layer B
registry reads them at runtime. Three ways to edit:
- Telegram — talk to Warren: the
modifyportfolio/modifywatchlistskills add/remove tickers interactively (inline buttons, confirmation message). - Edit the JSON files on the VPS (
/opt/apps/stock-tracker/*.json) — picked up at the next run, no n8n change needed. - Git — commit the change; deploy syncs the files.
The v5 bridge also writes to
watchlist.json, but only entries tagged source: "smallcaps-v5" — it never
touches the ones you added.
Each entry needs symbol, name, sector (see docs/ticker-files-schema.md).
New tickers are validated against the Layer B registry; unresolvable symbols are
quarantined (market_intelligence/data/quarantine.json) instead of corrupting analysis.
Warren stores raw news per ticker in /home/warren/.openclaw/workspace-warren/memory/tickers/.
- One file per ticker:
SYMBOL.md - Max 3 entries, newest first, separated by
--- - No longer auto-populated — the Layer A news collection was removed (Epic 6 S1); existing entries remain
- Read by the
tickerbriefskill: on-demand context without triggering a new search - To reset a ticker's memory:
rm memory/tickers/SYMBOL.md - To reset all memory:
rm memory/tickers/*.md
# Status (services + reliability timers)
sudo systemctl status stock-tracker warren-server openclaw-warren
systemctl list-timers eod-watchdog.timer outcome-tracker.timer v5-bridge.timer
# Restart all
sudo systemctl restart openclaw-warren warren-server stock-tracker
# Logs
sudo journalctl -u stock-tracker -f # n8n logs
sudo journalctl -u openclaw-warren -f # OpenClaw logs
sudo journalctl -u eod-watchdog.service -n 20 # last watchdog verdict
sudo journalctl -u outcome-tracker.service -n 20
sudo journalctl -u v5-bridge.service -n 20 # last bridge reconciliation
# "Why did I receive nothing tonight?" — the run explains itself
tail -1 runtime/market_intelligence/runs.jsonl | python3 -m json.tool
# Dedup state admin (never edit the JSON by hand)
python3 -m market_intelligence.dedup_admin show
python3 -m market_intelligence.dedup_admin reset --ticker SYMBOL| Variable | Description |
|---|---|
ANTHROPIC_API_KEY |
Anthropic API key (OpenClaw gateway — on-demand Warren) |
TELEGRAM_TOKEN |
Telegram bot token |
TELEGRAM_CHAT_ID |
Target chat ID |
TWELVE_DATA_API_KEY |
Layer B EOD data fallback (yfinance primary) |
SMALLCAPS_API_URL |
smallcaps-screener API, read by deploy/screener-push.sh on the workstation (default http://localhost:8000, loopback only). Unused on the VPS: the bridge reads the pushed snapshot. |
NODE_FUNCTION_ALLOW_BUILTIN |
Must include fs — n8n Code nodes read the ticker JSON files |
N8N_PORT |
n8n HTTP port (default: 5680) |
N8N_USER_FOLDER |
n8n data directory |
N8N_BASIC_AUTH_USER |
n8n login username |
N8N_BASIC_AUTH_PASSWORD |
n8n login password |
N8N_ENCRYPTION_KEY |
Credentials encryption key (generate once, never change) |
GENERIC_TIMEZONE |
n8n display timezone (Europe/Paris). Layer B cron is UTC by design (DST safety) |
GMAIL_USER / GMAIL_PASS |
Legacy — email delivery was removed; Telegram only |
Specs and decision log live in the Notion epics database (« Epics Stock Tracker »)
and the Obsidian vault (Memory/stock-tracker/epics/). Key choices:
sector_rotation.py+fear_greed.pyzero-LLM — quantitative signals available on demand without adding LLM cost- Layer A news collection removed — Epic 6 S1 dropped the automatic Haiku news + Warren filter pipeline;
memory/tickers/is now read-only context (tickerbrief) - Warren HTTP bridge removed — Epic 6 S4 dropped the dead n8n→OpenClaw bridge; the EOD digest is fully deterministic and Warren is on-demand only (Telegram "point sur X")
- No LLM in the alert path — Layer B anomalies and the digest are pure statistics; Warren is on-demand only, never on the critical path
- Beta gate = market-model regression (not naive z-score comparison) — avoids false alerts on broad risk-off days for high-beta names
- MAD scale, not standard deviation — robust to the fat tails of speculative small-caps
- Hysteresis dedup — one alert per event, not per day; re-arms when the ticker calms down
- Layer B cron in fixed UTC (21:30) — Paris-time cron ran before the US close for ~3 weeks each March (EU/US DST mismatch)
- systemd over PM2 — PM2 not available on this VPS; systemd provides equivalent reliability
- Dry-run by default, commit after delivery — a manual run must never mutate production dedup state; better a duplicate alert than a lost one (PM-0001 / NUAI incident)
- Watchdog outside n8n — its purpose is to detect n8n being dead, so it cannot live inside n8n
- LLM output is untrusted input — HTML-escaped producer-side before any Telegram parse
- Workflow import must publish a version — n8n executes the published version, not the draft; the deploy tooling asserts it
- Measure gated candidates too — the cost of a missed alert is measured the same way as the noise of a sent one