Sentinel Suite · NinjaTrader 8 · living documentDocs home →
Status: ✅ BUILT & LIVE (updated 2026-07-14). All phases below shipped; the sections that read as "to build" / "open decisions" are kept for the design rationale but are RESOLVED — see §9 Built state for what is actually running. Goal (achieved): turn the trade/tick records into a persistent, always-on, filterable data layer with two graphical front-ends — Plotly (per-trade path inspection) and Grafana (dashboards) — so you open a URL and the data is there, no copy-paste from chat, no one-off scripts.
What shipped since this spec was written: SQLite Sentinel\Lab\db\sentinel.db (WAL) + a live --watch
ingester + Streamlit :8501 + Grafana :3000, all auto-started by the SentinelDataPlatform scheduled task.
And (2026-07-14) the ingester now folds the Council VOTE VECTOR into the DB — the decision inputs
(votes/netScore/activeW/reasons) live only in the council\1.3\ ROW corpus, not the tick sidecars, so the
ingester reads both: it enriches each tick-true trade with its vote vector and backfills the historical
graded fires. DB went 702 → ~5,300 trades, ~99% carrying the vote vector. See [[ingester-vote-vector-fold-in]].
Related: [[sentinel-ml-lab]] (the "analyze the PATH, go tick-true" redirection), the Deck tape capture
(Deck_v0_2_5.cs, raw-tick OnMarketData), the ExcursionRecorder corpus (SentinelExcursionRecorder_v2_0_0, v2.1.2).
You trade in NT ─▶ Deck writes JSONL tick files ─▶ Ingester loads them ─▶ SQLite ─┬─▶ Streamlit+Plotly (localhost:8501)
(+ Council fires) (Excursions\ticks\, council\) (runs on a schedule) (one DB) └─▶ Grafana (localhost:3000)
You never touch files or paste links. The ingester keeps the DB current; you go to either front-end and filter.
sentinel.db; Streamlit and Grafana both
read it. Rationale: embedded (no server), one file, pandas-native, and Grafana has a first-class SQLite
datasource plugin. (Postgres/Influx only if we later need multi-writer or live streaming — not now.)px column now (currently the bar Close[0]) and adds
last/bid/ask when the capture goes raw-tick via OnMarketData (see §6). No schema churn to upgrade.Sentinel\Lab\db\sentinel.db)trades — one row per captured trade/fire| column | type | notes |
|---|---|---|
trade_id |
TEXT PK | the JSONL tradeId |
source |
TEXT | manual (Deck tape) · council (recorder) · godtrades … |
inst · bartype · account |
TEXT | |
dir |
INT | +1 long / −1 short |
entry_utc · entry_px |
TEXT · REAL | |
exit_utc · exit_px |
TEXT · REAL | null while open |
max_fav_ticks · max_adv_ticks |
REAL | |
n_ticks · dur_s |
INT · REAL | path length |
partial |
INT | 1 = capture armed mid-trade (entry not seen) |
schema · src |
TEXT | fidelity discriminator: schema='ctick.1'+src='last' = tick-true sidecar · schema='1.3'+src='row' = bar-based ROW trade folded from council\1.3\ (no tick path) · src='row' twins are dropped when a real sidecar lands |
| council context (nullable) | filled for council trades — the VOTE VECTOR is folded in from the council\1.3\ row corpus (the sidecars don't carry it) |
|
conviction · size_mult · net_score · active_w |
REAL | |
votes_json · reasons · episode_id · first_touch |
TEXT/INT | ~99% populated after the fold-in |
barrier_ticks · ms_to_target_r · ms_to_stop_r |
REAL/INT | tick-true first-touch (sidecar) |
| derived path features (computed at ingest) | the "analyze the path" columns | |
time_to_mfe_s · time_to_mae_s |
REAL | |
mfe_mae_ratio |
REAL | |
adverse_first |
INT | 1 = took heat before it worked (late-ish) vs 0 (favorable first) |
ingested_utc |
TEXT |
ticks — the path, one row per tick| column | type | notes |
|---|---|---|
trade_id |
TEXT | FK → trades |
ms |
INT | ms from entry |
px |
REAL | bar close now; raw last after the fidelity upgrade |
last · bid · ask · vol |
REAL/INT | added at tick.2 (raw-tick capture) |
PRIMARY KEY (trade_id, ms), index on trade_id. A trade_features VIEW can pre-compute filter columns
(conviction band, R buckets) for Grafana.
| # | component | path | job |
|---|---|---|---|
| A | DB schema | Lab\db\schema.sql |
create trades + ticks (+ view) |
| B | Ingester | Lab\ingest\ingest.py |
scan Excursions\ticks\*.jsonl and council\ticks\*.jsonl → parse header+ticks → compute path features → upsert into SQLite. THEN ingest_council_rows() folds council\1.3\*.jsonl (the vote-vector corpus) in — enrich existing tick-true trades' NULL vote columns, else insert a bar-based src='row' trade; per-file mtime watermark (row_files table) keeps --watch cheap. Idempotent (skip unchanged by mtime). WAL + busy_timeout so a backfill and the watch loop coexist. Modes: one-shot · --watch (poll 2 s) · --reingest (force full backfill) |
| C | Streamlit explorer | Lab\viz\explorer.py |
reads SQLite; sidebar filters (date · inst · bartype · dir · source · conviction band · partial · min ticks); a trade table → click → Plotly path chart (fav-ticks + raw price) + aggregate panels (median path by conviction band, MFE/MAE scatter). Supersedes the file-based viz\tickpaths.py. |
| D | Grafana provisioning | Lab\grafana\ |
datasource YAML (SQLite → sentinel.db) + dashboard JSON: (1) blotter table (2) path viewer (3) aggregates by conviction/bartype (4) capture-health (records/day, missing exits) |
| E | Always-on runner | Lab\run\ |
scripts/tasks to keep B (ingest), C (Streamlit), D (Grafana) up so the browser is always live |
pip install watchdog (for --watch), nothing else — SQLite is stdlib (sqlite3).python Lab\ingest\ingest.py --init (runs schema.sql) then a first full ingest of the existing 4 tick files.frser-sqlite-datasource plugin, drop the provisioning files from Lab\grafana\. Grafana → localhost:3000.streamlit run Lab\viz\explorer.py → localhost:8501.ingest.py --watch (or every 60 s) at logon → new tick files land in SQLite within a minute.Today px = the Deck tape's Close[0] = the brick close on TBars/HA bar types, not raw last-trade.
Good for shape, not for fill-level analysis. The upgrade (separate build, tracked in [[sentinel-ml-lab]]):
- Deck tape → raw ticks via OnMarketData (MarketDataEventArgs.Price), schema tick.2 adding last/bid/ask/vol. The DB schema already reserves those columns.
- Tick-true Council-fire recorder — the recorder currently stores peaks + minute snapshots, no path. A tick-true version logs the full raw-tick excursion per fire → lands in the SAME trades/ticks tables (source='council') → lets us finally test conviction vs. path QUALITY, not a coarse binary.
Close[0] px, flagged). → "go to localhost:8501, filter."OnMarketData, schema tick.2) → re-ingest.tickpaths.py; Dash if you want a more app-like custom UI.Sentinel\Lab\db\sentinel.db (Lab-owned, recommended) vs Sentinel\sentinel.db.RESOLVED (all of §7–§8): SQLite at
Lab\db\sentinel.db· Streamlit+Plotly ·--watchingester · Grafana native — every recommended option was taken and shipped. See §9.
Sentinel\Lab\db\sentinel.db, WAL journal + busy_timeout=30000 (readers never block the writer; a
backfill and the live watch loop coexist). ~14.8k trades, ~6.5M ticks and growing (was ~5,300 at the 07-14
fold-in). ~99% of council trades carry the vote vector after the fold-in.Lab\ingest\ingest.py) — two passes per scan: (1) tick sidecars (Excursions\ticks\ + council\ticks\)
→ trades+ticks; (2) ingest_council_rows() folds council\1.3\ — the ONLY place the vote vector lives —
enriching tick-true trades and backfilling historical no-sidecar fires (src='row'). Discriminator: src='last' =
tick-true, src='row' = bar-based row; a row twin is deleted when its real sidecar arrives (any order). Backfill:
python ingest\ingest.py --reingest. See [[ingester-vote-vector-fold-in]].SentinelExcursionRecorder_v2_0_0 (v2.1.6, schema 1.4 / ctick.3). Writes the schema ROW
(council\1.4\) + a per-fire tick-path sidecar (council\ticks\, self-describing ctick.3 header). Streams each row to disk the moment its excursion window completes
(~60 min post-fire) instead of buffering to session-end, bounding crash-loss of the vote vector to the in-flight
window.viz\explorer.py → localhost:8501 (+ the Council Paths page); Grafana →
localhost:3000 (SQLite datasource).SentinelDataPlatform scheduled task runs Lab\run\sentinel-data.bat at logon (guarded by
"is :8501 up" so it never dupes), launching the .venv ingester (--watch) + Streamlit; Grafana runs as a service.
⚠ Self-heal is logon-level, not process-level — a killed ingester is not respawned mid-session.A second, orthogonal surface: operational health of NT + the whole Sentinel suite, distinct from the trade-corpus analytics above. Split by design: Grafana = ops/health ("am I safe to trade right now"); Streamlit/Observatory = research ("does the edge exist"). Don't blur them.
Lab\health\probe.py) — samples every 30 s, READ-ONLY on NT (files/process/ports only: state.json,
sentinel.log, the Ledger, tasklist, socket port checks — never NT internals or orders, so a crash here can't touch
trading). No new deps (tasklist, not psutil). Single-instance via a 127.0.0.1:8502 bind (a blind launcher start is
safe; the bound port doubles as the "probe up" signal). Crash-resistant --watch loop (each sample in try/except).sentinel.db, WAL + busy_timeout): health (wide time-series: NT up/responding, kill,
connections, feed/risk, service freshness, rolling 5-min err/crit/contention/naked counts, live-Council count, fires
today, last conviction, DB/WAL size), governor_health (per-account day P&L vs cap / loss-stop / status), arc_slots
(per-slot health · pos_qty→naked · fills · P&L), roster_health (per-scope present/declared/missing/unexpected, parsed
from the Council log line), health_event (deduped discrete transitions: NT down, kill engaged, …).feed_health (per-INSTRUMENT lag/stall from state.json.risk.feeds — already published by
RiskService/StateService, no C# change needed; empty until a feed is active), connection_health (per broker),
eye_health, copier_health, veto_5m, scope_health (quiet-Council detector — seconds since last verdict,
bounded to recently-active scopes). health also carries NT CPU%/RAM/uptime (psutil, CPU normalized by core
count), disk free/used%, day-P&L total, win-rate/trades today. Schema grows via an idempotent _migrate().Sentinel · Health (uid sentinel-health, Lab\grafana\dashboards\sentinel-health.json, 52 panels,
4 rows — 🛡 Safety · 🧠 Brain · 💰 P&L/accounts · 🔩 Resources/infra). Generated by Lab\grafana\build_health_dashboard.py
(edit the generator, re-run; the provider auto-reloads within 30 s, allowUiUpdates:true). → localhost:3000/d/sentinel-health.frser-sqlite plugin → every dashboard reads
"No data" (plugin.notRegistered); fix = reinstall + conf\custom.ini allow_loading_unsigned_plugins + restart
(self-heal now in sentinel-data.bat). (2) frser reads an integer time column as SECONDS → every time-series
query selects ts_ms/1000 AS time (raw ms → "Data outside time range").--watch + a Grafana-plugin heal were added to Lab\run\sentinel-data.bat before the
:8501 skip-guard, so they run even when the rest of the platform is already up (the probe's :8502 guard prevents
dupes). Same logon-level self-heal caveat as the ingester.The corpus is append-only and lives on one disk. Two failure modes it doesn't defend against on its own: a training run is not reproducible (you can't re-fit against "the corpus as it was three weeks ago"), and a contamination event (the replay-leak class, [[corpus-hygiene-and-fill-fidelity]]) has no clean rollback point. A tiered, timestamped snapshot ladder answers both. Built 2026-07-17.
Engine: Lab\snapshot\snapshot.py (subcommands daily / weekly / verify <dir> / list; flags
--dry-run, --date, --week). Wrapper Lab\run\sentinel-snapshot.bat. Runs under Lab\.venv. Every run
appends to Sentinel\sentinel.log as [SNAPSHOT] / [SNAPSHOT-CRIT].
Three tiers, validate-before-destruct:
Excursions\ tree itself. The recorder (§9, v2.1.2) already streams each row to disk
crash-safe the moment its window completes, so live is the continuous session-durable record — a discrete
session copy (or an NT session-close hook) was rejected as redundant + fragile. Live is the ground truth
the daily captures; there is no separate session artifact.Snapshots\daily\<date>\ — a point-in-time zip of the whole corpus + a consistent copy of the
~608 MB WAL-mode sentinel.db via VACUUM INTO (checkpoints + compacts to one clean file; a raw file-copy
of a WAL DB is inconsistent). ~118 MB zipped, ~25 s. Pruned only after the covering weekly validates.weekly → Snapshots\weekly\<isoweek>\ — the permanent master, kept forever. Validates it is a
row-hash superset of the union of that week's dailies, self-heals any gap into _healed.jsonl, then
destructs the validated dailies.
Validation = superset-of-row-content-hashes, NOT a file diff. Corpus files grow append-only through the day,
so a byte-compare is useless, but "every line I had before is still present" is exact and schema-agnostic. Each
snapshot's manifest (per-line sha256) is built in the same pass that writes the zip → manifest ≡ zip by
construction, re-checkable any time with verify. The daily's own check is file-presence (a file present at
start but unreadable = a real miss → status gap); the row-superset check is a weekly-only concept because
the dailies are frozen there — and that check is what catches live shedding a file (a schema uplift moving it
to _archive, a manual delete), the ladder's real WORM payoff.
⚠ Design bug caught by driving it: the first cut validated a daily snapshot's rows ⊇ a live re-read, which falsely flagged normal forward growth as a gap. A point-in-time snapshot cannot be validated against a still-appending corpus — only against frozen lower tiers.
⚠ Honest caveat (the same shape as §6): the ladder guarantees archive INTEGRITY (no row is ever lost),
not corpus CORRECTNESS. A superset check preserves poisoned replay rows as faithfully as clean ones —
contamination stays corpus_probe's job ([[corpus-hygiene-and-fill-fidelity]]), not this ladder's. Snapshots
freeze what the corpus was; they don't judge it.
Schedule (box is Central; aligned to the CME daily maintenance break so the corpus is settled + the market closed during a run):
SentinelSnapshotDaily — every day 16:30 CT (mid-break, before the 17:00 reopen).SentinelSnapshotWeekly — Sunday 16:45 CT (after Sunday's daily, before reopen; the whole Mon–Sun iso-week
is closed ⇒ no orphaned dailies, zero live writes during the run).Registered via Register-ScheduledTask, LogonType S4U — runs whether logged on or not, no stored
password, keeping the Administrator identity so file ACLs / profile paths / the venv resolve exactly as the
logged-in run (chosen over a SYSTEM principal, which runs in a different profile context). Both verified firing
headless: LastRunResult 0x0.
Operate: sentinel-snapshot.bat daily|weekly|list; adjust times in Task Scheduler; disable via
Disable-ScheduledTask SentinelSnapshot*.
Disk trajectory (flagged): weekly master ≈ 118 MB × 52 ≈ ~6 GB/yr, kept forever (deliberate: full history).
The DB dominates and is regenerable from the corpus rows via ingest.py, so the easy future lever if disk
matters is weekly = corpus-rows-only + keep just the latest DB. Full detail: [[corpus-snapshot-ladder]].
Built after §10–§11, all to the SAME recipe (read-only probe → sentinel.db → a themed Grafana board; a
guard-port singleton; wired into sentinel-data.bat before the :8501 guard):
Lab\health\corpus_probe.py (guard :8503): reconciles the recorded corpus
(Ledger ↔ rows ↔ sidecars), schema hygiene, replay-leak → corpus_integrity/corpus_folder/corpus_events.Lab\health\legacy-node_probe.py (guard :8504): SSH-polls the remote bake worker
(Tailscale worker1) → legacy-node_health/legacy-node_event → board Sentinel · legacy-node
(localhost:3000/d/sentinel-legacy-node). Surfaces NT render-thread death / bake stall / unreachable. [[distributed-backtest]]Lab\docs\audit.py (guard :8505, 15-min): scans the docs for drift (broken links,
stale HTML, contract version-drift, dangling tokens, orphans) → docs_health/docs_finding → board
Sentinel · Docs (localhost:3000/d/sentinel-docs); --errors-only is a git pre-commit gate. [[docs-health]]Guard-port registry: 8501 Streamlit explorer · 8502 Health probe · 8503 Corpus probe · 8504 legacy-node probe · 8505 Docs-health probe · 3000 Grafana. Four boards, one sentinel.db: sentinel-trades · sentinel-health
· sentinel-legacy-node · sentinel-docs.
§10–§12 answer is the platform alive. This answers a different and harder question: is what it just recorded worth keeping? A bake can run for hours at full speed, write a clean-looking corpus, and be worthless because one voter never reached a single row.
Lab\verify_votes.py — per-lane completenessStdlib-only, so it runs on the bake worker as well as the main box. Three checks per lane:
| Check | Rule |
|---|---|
| SEAM | the bar type's own voter(s) MUST be present — derived from the bar-type id, so it needs no config (212201/212202 → BRK · 212203 → FLUX · 212204 → BRK+CVB). 🔴 This map covers 4 of the 7 shipped bar types — 212205 Lattice · 212206 Effort · 212207 Tide are ABSENT, so a lane on one of them passes SEAM by having nothing to check. Flagged 2026-08-10, not guessed: filling it in is a claim about which seams each type actually publishes and must be read off the bars types. Same defect class as the Core name registry completed in v1.48.0 — an id-keyed table that silently covers only the ids someone remembered. |
| DECLARED | every Roster.conf voter (same cascade the Council uses) present as a KEY. Absent = CRIT; present on <90% of rows = WARN — an intermittent dropout a union-of-rows check would hide |
| BRK LEVELS | brick lanes must carry brkUpper/brkLower, or limit-vs-market grading is dead (Flux exempt by construction) |
EXIT 0 = all lanes complete · 1 = WARN (partial, or too thin to judge) · 2 = CRIT (missing data).
A voter recorded as
0counts as PRESENT. Abstention versus a missing key is the entire distinction — a voter that legitimately has no opinion must not read as a broken sensor.
It windows on FILE MTIME, not fireTime — and that was a defect in the gate itself, found by driving it. A
replay bake writes rows whose fireTime is historical, so a fireTime window silently skipped the whole
replayed corpus and would have passed the very bake it was built to catch. Written-at is the only clock that
means "this bake, now" for live and replay alike.
Lab\health\corpus_probe.py imports it directly (import verify_votes as _votes) and runs it every 300 s,
writing a per-lane vote_health row plus per-lane corpus_events with the existing change-only de-dup,
so each lane alerts and recovers independently. Surfaced as the 🗃 Corpus row on Sentinel · Health
(localhost:3000/d/sentinel-health): lanes-missing-a-voter / partial / brick-lanes-without-levels, the per-lane
table, and the event log.
⚠ Nothing rendered
corpus_eventsbefore this. The corpus probe had been writing to a DB no board watched — a monitor whose output nobody could see is not a monitor.
The probe's busy_timeout was also raised 8 s → 30 s. Measured, not guessed: it was losing the race with
ingest.py --watch on the multi-GB WAL DB and skipping whole sample cycles — a monitor that silently stops.
Start a bake, let it run ~10 minutes, run python Lab\verify_votes.py --days 1, and require EXIT=0 with
every lane "complete" before committing to the long run. This is the standing procedure in
SENTINEL_RUNBOOK.md §4b ②.
lab_faults.swallow()The Lab's counterpart to SentinelCore.Swallow on the C# side. Same problem, same fix: a probe, an
ingester or a Streamlit page must never die because one malformed row failed to parse — but don't
propagate had been implemented as don't record, so a component could fail continuously and silently.
The cost is on the record: ingest.py --watch ran for three days against a schema it could not read
(§9), and nothing said so.
Lab\lab_faults.py — stdlib only, no dependencies, so verify_votes.py and the health probes stay
deployable standalone to a bake node.
from lab_faults import swallow
try:
row = json.loads(line)
except json.JSONDecodeError as _swex:
swallow("ingest.parse", _swex)
continue # control flow is UNCHANGED -- swallow() goes before it, never instead of it
Contract (deliberately identical to the C# one): never raises · never alters control flow ·
rate-limited per tag (first 3, then 1/min — the flood fear that made empty handlers attractive) ·
counts everything including throttled occurrences, so fault_total() is honest.
| Surface | What it gives you |
|---|---|
Lab\logs\lab-faults.log |
timestamped tag / exception type / message / file:line / pid, 5 generations of rotation |
faults() / fault_total() |
per-tag and total counts for the running process |
python -m lab_faults |
tail the log; --clear rotates by hand |
Retention is 5 generations, not one, on purpose. Single-generation rotation destroyed a live forensic window twice in one night during the BRK/FLUX investigation (see NOW.md and SENTINEL_RUNBOOK.md §4b ⑤).
Migration state (2026-07-25): all 53 silent handlers across 23 Lab files now record —
health\probe.py 11 · docs\audit.py 5 · verify_votes.py 5 · viz\observatory.py 5, and the rest.
Zero bare except: remain. Verified by driving, not by reading: every file imports, verify_votes.py
and docs\audit.py produce identical output to before, and two real sites (docs.audit._read on a
missing path, sentinel_lab.bartag.bartype_name on a bad tag) were confirmed to return their original
fallback values and write a fault line.
health\probe.py now tails the fault log every cycle into a lab_faults table (per-tag rollup over
24h) plus two columns on the always-written health row, and the Sentinel · Health board gained a
🧯 Lab faults — what failed quietly in the Python row:
Swallowed faults · 24h · Distinct fault tags · Processes affected · Suppressed (not logged), over a
per-tag table (tag · occ · logged · procs · first · last · detail). Amber, not red — a swallowed fault
is something to look at, not something that stops trading, and reserving red for the safety row keeps the
board's alarm vocabulary meaningful. health_event gains labfaults (level change) and labfault_new,
which fires once per newly-seen tag — a brand-new silent failure is the thing worth surfacing; a
known steady one is noise.
Three design points, each of which is the difference between a real monitor and a decorative one:
health, not lab_faults. health gets a row every cycle whether or
not anything failed, so 0 means "measured zero just now." Reading an empty lab_faults would
show 0 for a dead probe too — reproducing, on the board built to abolish it, the exact ambiguity
between nothing happened and nothing is watching. (Same defect as §9's ingester liveness being
inferred from Streamlit's port.)occurrences ≠ lines, and the gap is displayed. Rate limiting suppresses without writing a
line, so 7 hits inside one 60 s window write 3 lines and no marker — anything counting lines would
report 3 and be confidently wrong. swallow() therefore writes a per-tag exit summary
(SUMMARY n occurrences this process) via atexit, which the probe treats as authoritative per pid.
⚠ Still a lower bound by nature: a hard-killed process runs no atexit handler.🐛 Found by the monitor watching itself, on its first run: lab_faults() tailed the .1 rotation
generation before it existed, swallow()ed the FileNotFoundError, and so manufactured a fault every
cycle — a monitor generating the very signal it reports. Fixed with an existence check. Verified the
fix by driving it: three consecutive runs on a clean slate produce zero faults and no log file at
all, then 6 real occurrences report as occ=6, logged=3, suppressed=3 end-to-end through the live
daemon.
🔧 Also fixed while here: probe.py bound its :8502 single-instance guard before the one-shot
branch, so python probe.py refused to run while the daemon was up — making the probe un-inspectable
exactly when you most want to inspect it. The guard now binds only under --watch, matching
corpus_probe.py, which already had it right.
⚠ Operational note: the probes are long-lived Python processes and Python does not reload source.
After editing probe.py you must restart the probe or the loop keeps running the old code — the
same class of failure as §9's three-day-stale ingester. sentinel-data.bat restarts it guarded, so a
blind re-run is safe.