Sentinel Suite · NinjaTrader 8 · living document·Docs home →

Sentinel Data Platform — Spec & Stand-Up Workflow

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


1. North star (the workflow you described)

  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.


2. Architecture decisions (with rationale)

  1. NT writes files, Python owns the DB. NT never talks to SQLite. NinjaScript stays simple and crash-safe: the Deck/recorder append JSONL (durable, append-only, no DB driver in the trading process, no lock contention with a live account). A Python ingester reads those files into SQLite. This decouples the trading process from the analysis store — a bug in analysis can never touch order flow.
  2. SQLite is the single source of truth for BOTH front-ends. One 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.)
  3. Plotly and Grafana are complementary, not redundant. - Streamlit + Plotlyinspect ONE trade's path interactively (the favorable-ticks shape, entry heat, raw price), + ad-hoc aggregates. - Grafanadashboards over the whole corpus (blotter, aggregate stats by conviction band / bar type, capture health), live-refreshing.
  4. The DB schema is fidelity-forward. It carries a 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.

3. Data model (SQLite: 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.


4. Components to build

# 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

5. Stand-up workflow (one-time)

  1. Python deps (Lab venv already has pandas/plotly/streamlit): pip install watchdog (for --watch), nothing else — SQLite is stdlib (sqlite3).
  2. Create the DB: python Lab\ingest\ingest.py --init (runs schema.sql) then a first full ingest of the existing 4 tick files.
  3. Grafana: install Grafana OSS (Windows installer or Docker), install the frser-sqlite-datasource plugin, drop the provisioning files from Lab\grafana\. Grafana → localhost:3000.
  4. Streamlit: streamlit run Lab\viz\explorer.pylocalhost:8501.

5b. Always-on (so the data is "just there")


6. Fidelity prerequisite (the honest caveat)

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.


7. Phased plan (build order)


8. Open decisions (confirm before Phase 0)

  1. SQLite (recommended) vs Postgres vs InfluxDB. SQLite unless you foresee live-streaming dashboards.
  2. Streamlit+Plotly (recommended, already built) vs Plotly Dash for the "Plotly" front-end. Streamlit is faster to stand up and reuses tickpaths.py; Dash if you want a more app-like custom UI.
  3. Ingest cadence: scheduled task every ~60 s (recommended, simple) vs a live file-watch daemon.
  4. DB location: Sentinel\Lab\db\sentinel.db (Lab-owned, recommended) vs Sentinel\sentinel.db.
  5. Grafana install: native Windows service (recommended for always-on) vs Docker.

RESOLVED (all of §7–§8): SQLite at Lab\db\sentinel.db · Streamlit+Plotly · --watch ingester · Grafana native — every recommended option was taken and shipped. See §9.


9. Built state (2026-07-14) — what is actually running

10. Health layer (2026-07-15) — "is everything alive & safe"

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.

11. Corpus snapshot ladder (2026-07-17) — "a frozen, reproducible corpus + a recoverable one"

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.

12. Additional surfaces (2026-07-19) — more probes, more boards, one DB

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):

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.


13. The corpus ACCEPTANCE GATE (2026-07-24) — "are the rows even usable?"

§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 completeness

Stdlib-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/212202BRK · 212203FLUX · 212204BRK+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 0 counts 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.

Wiring — probe → table → board

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_events before 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.

Operator use — the preflight

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


14. Fault recording in the Lab (2026-07-25) — 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.

14b. The 🧯 Lab-faults row on the Health board (same session)

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:

  1. The headline counts read 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.)
  2. occurrenceslines, 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.
  3. The roll-up is recomputed from the file every cycle — no watermark. A probe that has to remember where it got to is a probe that silently double-counts or skips after a restart.

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