Viska IBKR / Flex Trading-Data Stack

The nightly pipeline that turns Interactive Brokers Flex statements into Viska's authoritative trading.* book — architecture, full development history, the end goal, and the gates still open. Compiled from git history, devlogs, specs, and the live workflow + service config.
report · 2026-06-23 epic viska-pm#7 · board #8 repos: ViskaDB · ViskaN8N · Mastra grounded: every claim file-cited

§0TL;DR & current state

The Phase-1 single-day pipeline is live end-to-end in production — a real round-trip wrote June-19 holdings to the database. A Phase-2 multi-day upgrade is coded, merged, and tested, but its deployment to the running Railway service is unconfirmed, and the nightly schedule sits behind one operator activation gate.

LIVE
Phase-1 single-day pipeline (exec 35607: 21 positions, 5 FX, 3 NAV rows written)
CODED
Phase-2 multi-day (Fix A + Fix B merged, 75+7 tests green) — deploy unconfirmed
1 gate
Operator viska.gg visual check → arms the 22:30 nightly pull
10
trading.* tables written per statement, idempotently

The one thing to know

Merging Phase-2 (Fix A, 31ae127) to main did not necessarily ship it. ViskaDB has no railway.toml / Procfile / GitHub-Actions deploy config — the service is deployed imperatively (Hades / operator via Railway). So the live apply-svc may still run the older single-day image. A manual redeploy + confirm is the real remaining technical step. See §9.

§1The end goal

The IBKR Flex stack is the data foundation of the "Viska Trading Intelligence" product (board #8, epic viska-pm#7). Its job: keep an authoritative, daily-updating book of the fund's holdings, NAV, cash, FX, trades and dividends in Postgres, so every downstream surface reads one source of truth instead of stale manual snapshots.

Mímir Slack bot consumer

Reads trading.v_* views to answer live portfolio questions and generate the daily Morning Brief (board #8 WS-4). migrations/20260610-03-trading_views.sql:4 — "Mimir bot API contract"

viska.gg dashboard consumer

Renders live positions + NAV + theme allocation via public.* views derived from trading.*. producers/README.md:1–12

Trading decision layer consumer

ViskaStrat / ViskaTrader (wr-viska-live) consume positions, P/L and FX for the idea→gate→decide→execute loop (board #5).

Corpus WS-A consumer

Trading facts feed the verified research corpus that backs morning-brief lede generation and the council ensemble (board #9).

Frame: CLAUDE.md:69 — board #8 "data product — IBKR/Flex ingest, trading schema, morning brief, corpus WS-A".

§2Architecture at a glance

Three moving parts, one ruling decision. The ruling decision (055-A, operator-ratified): n8n is pure transport; apply-svc is the SSOT parser. n8n pulls the raw Flex XML and uploads it untouched; the Railway service re-parses it and is the only writer to trading.*. This removed an earlier ambiguity about where parsing lives.

① n8n workflow live

ADeTjBZiHOMIXzvr — "Viska Macro Daily — IBKR Flex Pull". Triggers, polling, normalize, guard, upload, apply. Transport only.

② apply-svc on Railway deployed

flex-apply-svc-production.up.railway.app. Flask via gunicorn 'apply_service:create_app()'. Endpoints /apply, /sign-upload, /healthz. The SSOT writer.

③ Supabase live

Private flex-inbox Storage bucket (raw XML) + the trading.* schema (10 tables) + public.* read views. The book of record.

The Railway service surface

EndpointAuthDoes
POST /sign-uploadBearerServer-mints a short-lived Supabase signed upload URL (service-role key never leaves the server). apply_service.py:177–220
POST /applyBearerFetches the raw XML from the bucket, parses per-day, writes trading.* in one transaction, archives the object, alerts on failure. apply_service.py:125–169
GET /healthznoneLiveness → 200 {"ok":true}. No build/version field — the live image can't be fingerprinted without Railway creds. apply_service.py:357–359

Environment surface (read in create_app())

VarPurpose
FLEX_APPLY_WEBHOOK_SECRETStatic Bearer token for both endpoints (constant-time compare)
VISKA_DB_URLPostgres connection (service-role / owner) — the SQL apply target
VISKA_SUPABASE_URLSupabase project URL (Storage REST base)
VISKA_SUPABASE_SERVICE_ROLE_KEYService-role key — Storage sign / fetch / move
FLEX_INBOX_BUCKETBucket name (default flex-inbox)
PORTHTTP port (Railway-injected)

Cited: apply_service.py:322–331 (env reads) · :46 — FLEX_UPLOADER_JWT DROPPED in task 060.

§3The data flow, end to end

trigger n8n service apply-svc store Supabase
┌──────────────────────────────────────────────────────────────────────────┐
│  IBKR Flex Web Service  →  n8n (transport)  →  apply-svc  →  trading.*     │
└──────────────────────────────────────────────────────────────────────────┘

TRIGGER   cron 0 30 22 * * 1-5 (22:30, Mon–Fri)   ┐
          on-demand webhook  POST /viska/flex-pull   ┘ (header-auth)
                    │
IBKR PULL  POST .../FlexWebService/SendRequest  q=1384817 v=3
          → ReferenceCode → poll GetStatement (20s × up to 6) → raw FlexQueryResponse XML
                    │  guards: SendRequest "Success"? · Statement ready? (else Slack alert)
                    ▼
NORMALIZE Code node → normalizeFlexDataset()  (scripts/lib/flex-normalize.js)
          multi-day aware: report_dates[], position_count = Σ days, date_from/to
          guard "Statement Usable?"  position_count > 0 AND report_date present
                    │   (false → respond count=0, skip apply — empty-XML guard)
                    ▼
SIGN      POST /sign-upload (Bearer) ─────────────────────────────┐
          apply-svc mints short-lived signed URL (service-role, server-side)│
                    │◄──────────────────────── signedUrl ───────────────────┘
UPLOAD    PUT {signedUrl}  raw XML  →  Supabase Storage  flex-inbox/incoming/*.xmlAPPLY     POST /apply (Bearer)
          ├─ fetch raw XML from flex-inbox
          ├─ build_sections_by_day()  → one tuple per distinct report_date   ◄── Fix A
          ├─ emit_days()  → ONE BEGIN…COMMIT, per-day _stmt_<i> temp tables
          │                       ON CONFLICT throughout (idempotent)
          ├─ apply_sql() over VISKA_DB_URL
          ├─ move object → processed/  (best-effort, non-blocking)
          └─ on error → stderr alert + INSERT ops.health_events + 500 (retry-safe)
                    ▼
WRITE     trading.statements · positions_snapshots · nav_snapshots · fx_balances
          trading.trades · cash_flows · dividends · fees_interest · instruments
                    ▼
RESPOND   { ok, account, reportDate, report_dates[], statements, positions, fx, nav_rows }

READ      v_positions_current · v_nav_latest · v_pnl_monthly  → Mímir brief
          public.allocation_snapshots · positions_current · nav_latest → viska.gg

Cited: ViskaN8N flex-pull-ADeTjBZiHOMIXzvr.json:5–546 (nodes) · apply_service.py:104,116,125–169,227–299 · ibkr_import.py:142–407 · scripts/lib/flex-normalize.js.

§4The trading.* schema

Every table carries statement_id lineage back to the statement row that produced it. The importer writes all of them in one transaction per /apply call.

TableHoldsDedup key
statementsPer-(account, report_date) anchorUNIQUE(file_hash) = sha256("flex:{account}:{report_date}")
instrumentsSecurity master (conid → symbol)conid PK
instrument_themesOperator-editable taxonomyUNIQUE(symbol) WHERE valid_to IS NULL
positions_snapshotsPoint-in-time open positionsUNIQUE(statement_id, conid) + partial NULL-conid index (FX pairs)
nav_snapshotsNAV decomposition + TWRUNIQUE(statement_id)
fx_balancesFX position snapshotsUNIQUE(statement_id, currency)
tradesExecution recordsUNIQUE(conid, executed_at, qty, t_price, proceeds)
cash_flowsDeposits / withdrawalsUNIQUE(statement_id, settle_date, currency, amount, md5(description))
dividendsDividends + withholdingUNIQUE(statement_id, pay_date, symbol, gross, withholding)
fees_interestFees + broker interestON CONFLICT DO NOTHING
candlesEOD OHLC (n8n nightly job, separate)UNIQUE(symbol, d, source)

Cited: migrations/20260610-01-trading_schema_core.sql:17–79 · 20260610-02-trading_book.sql:11–194 · ibkr_import.py:181–352.

§5Idempotency — why re-firing is safe

Statement grain

file_hash = sha256("flex:{account}:{report_date}") — a semantic key, not a byte hash. One row per (account, report_date). ON CONFLICT(file_hash) DO UPDATE … RETURNING id. flex_import.py:20–31

Why not hash the bytes?

IBKR stamps whenGenerated fresh on every pull, so two pulls of the same trading day are byte-different but the same snapshot. A byte hash would mint a duplicate every night. The semantic key collapses them. task 056 / commit b1664ff

Each event table then carries its own UNIQUE constraint with ON CONFLICT DO NOTHING, so a re-fire of /apply with the same object produces zero net-new rows. This is what makes the 500-on-error / retry-from-inbox design correct, and what made the multi-day fix a pure importer change with no migration.

§6Development history

From operator directive to live pipeline in ~6 days. Phases below; status badges reflect state as of this report.

PHASE 0 · Foundation — CSV book (May → early June)

WS-1 stood up the trading.* schema + read-only views; WS-2 did a one-time IBKR Activity CSV first-import. That CSV book was the baseline — but static, not auto-updating. The whole Flex project exists to replace it with a live feed. STATE.md (ViskaN8N-010): "trading.* were populated by the one-time WS-2 CSV first-import — NOT auto-updating".

PHASE 1 · Flex XML parser + first live landing (Jun 17 → 19)

DateRefMilestoneState
06-17b2eefa7 · task 003Flex XML parser built (flex_to_sections.py + flex_import.py). Gate-stream snapshots at latest reportDate; XXE / billion-laughs guard. Verified vs operator dump (NAV $14.96M, 31 pos exact).live
06-17task 053Public anon views for the viska.gg 7-stream FE contract.live
06-19 PMprod applyIBKR token validated live (the "length-suspect" red herring; Hades re-mirrored vault). Live prod @ 06-18: 21 positions, NAV $15,525,205.04, 5 FX.live
06-19 PMb6434d4Task 003 Phase-2 code: daily NAV series + cash flows. Tested vs live statement (23 daily rows). Unpushed — held for CIO ratification.coded

PHASE 2 · Architecture decisions (Jun 16 → 22)

  • 06-16 — Operator directive: "live IBKR query to shift the SSOT on holdings." Task 054 filed; Flex token env-vars wired.
  • 06-17Decision 055-A ratified: n8n = transport, apply-svc = SSOT parser. Governs everything downstream.
  • 06-19CIO ratification packet prepared: 9 items A–P (ISK→NAV interim, TWR method, MTD rename, market-quotes vendor, fx_rates, allocation, TSLA theme, position count 31→21, public-data posture). awaiting signature

PHASE 3 · Apply-service + auth (Jun 21)

TimeRefMilestoneState
09:40b1664ff · task 056Semantic dedup key (account, report_date) replaces the wrong bytestream hash. +4 TDD.merged
13:28a814f3f · task 057 · PR #28Private flex-inbox bucket migration + authenticated apply_service.py (HMAC). Heph APPROVE.merged
14:15bb0eb83 · task 057 fix · PR #29HMAC → Bearer: n8n blocks $env and Code nodes can't read creds, so HMAC-over-body was dead on arrival. Constant-time Bearer instead.merged
17:55d182bcb · task 060 · PR #30POST /sign-upload: FLEX_UPLOADER_JWT failed 403 live because Viska Supabase signs ES256 (asymmetric), not HS256. Fix: server mints short-lived signed upload URLs via service-role. + path-traversal / scope-escalation hardening. +18 TDD (62 green).merged

PHASE 4 · Deploy, wire, go live (Jun 21 → 22)

DateRefMilestoneState
06-21 14:30Hades 058aflex-inbox bucket migration applied to prod + env bindings resolved (value-safe).prod
06-22Hades 058bapply-svc deployed to Railway GREEN. Smoke: 6×401 (auth-fail) + 1×200 (correct Bearer, no key leak).deployed
06-22ViskaN8N 059 · exec 35607Live end-to-end round-trip: IBKR pull 30 → sign 200 → PUT 200 → apply 200. June-19 holdings written (21 positions, 5 FX, 3 NAV rows).live
06-22 09:05MCP updateOperator insight: floating Flex date could overwrite good data with empty XML. "Statement Usable?" guard inserted before upload.live
06-22 22:30verify-onlyGround-truthed the "1 of 22 days lands" symptom on a real 22-statement pull: 1 report_date emitted, 21 dropped — confirmed by-design latest-reportDate filter, not a bug.verified
06-22 23:10f810998/31ae127Fix A — multi-day importer. build_sections_by_day() + emit_days() emit every distinct report_date in one transaction, per-day file_hash. No migration. TDD 10 new / 75 green. Real 22-day smoke: 22/22 land, idempotent.merged
06-22 23:10n8n#15 · 2f12e5cFix B — multi-day Normalize lib (scripts/lib/flex-normalize.js): multi-day-aware guard/metadata. 7/7 TDD green.merged

§7Decision records

DecisionWhat & whyState
055-A · apply-svc = SSOTn8n uploads raw XML; apply-svc re-parses and is the sole trading.* writer. Removes the parse-locus ambiguity.ratified
auth = BearerHMAC-over-body impossible in Viska n8n ($env blocked, Code nodes can't read creds). Static Bearer, constant-time compare.ratified
signed-URLsServer mints short-lived Supabase upload URLs (not a static JWT) — because the project signs ES256.ratified
ES256 signing (infra fact)Viska prod Supabase signs JWTs with ES256, not HS256 — the root cause behind the signed-URL design.verified
056 · semantic dedupDedup on (account, report_date), not raw bytes — IBKR restamps every pull.ratified
CIO items A–PNAV/ISK mechanism, TWR method, market-quotes vendor, public-data posture, etc. Gate Phase-2 full ingestion + the public live-flip.awaiting CIO

Captured in Viska-Wiki: [[flex-apply-service-auth-bearer]] · [[flex-upload-signed-urls]] · [[viska-supabase-es256-jwt-signing]].

§8Delivery status

ComponentBuildMergeDeployLive verify
Task 056 — dedup keyb1664ff
Task 057 — bucket + apply-svca814f3f/bb0eb83✅ 058a✅ 401/200 smoke
Task 060 — sign-uploadd182bcb✅ 058b✅ PUT 200 (exec 35607)
Task 059 — n8n wiring✅ June-19 data in DB
Phase-2 multiday — ViskaDB (Fix A)31ae127unconfirmedin-process only (22/22)
Phase-2 multiday — ViskaN8N (Fix B)2f12e5c✅ n8n#15node-sync pending
CIO ratification A–P✅ packetawaiting CIO
On-demand webhook✅ designblocked on creds

§9Current unknowns & blockers

Ordered by what stands between "coded" and "the nightly feed lands all 22 days in prod." Confirmed facts and honest unknowns are marked distinctly — the deploy trigger is genuinely unconfirmed and should not be assumed.

🔴 Blocker 1 — Multi-day deploy is unconfirmed (the live gate)

Confirmed: ViskaDB has no railway.toml, Procfile, or .github/workflows/ (verified by find). The Mastra/Mímir repo does auto-deploy on push (its railway.toml + DEPLOYMENT.md). apply-svc does not follow that pattern — its initial deploy was done imperatively by Hades.

Unknown: whether the Railway service has an out-of-repo GitHub auto-deploy hook. Best inference (chronicle "post-merge Railway redeploy" + Hades manual 058b + absent config) is that it is manual — meaning merging Fix A (31ae127) did not ship it, and the live image is likely still single-day. Cannot fingerprint the live commit (no /version route, RAILWAY_TOKEN unset).

Unblock: Hades or operator triggers a Railway redeploy off 31ae127 and confirms green; then a pinned-fixture POST confirms the 200 carries statements:22 / 22 report_dates.

🔴 Blocker 2 — Operator activation gate

Workflow ADeTjBZiHOMIXzvr is left INACTIVE pending the operator's visual check that the June-19 data renders correctly on viska.gg (the page is behind an auth wall ViskaFront can't self-verify). On operator "go", the 22:30 Mon–Fri schedule arms. This is the single gate between the live single-day pipeline and a running nightly feed.

🟡 Blocker 3 — CIO ratification A–P pending signature

Shapes the NAV total (ISK interim mechanism), TWR method, market-quotes vendor, and the public-data posture. Gates the Phase-2 full ingestion build (all 18 Flex sections, not just the 4 gate-streams) and the public live-flip. All code is ready except where the posture decision (item P) gates the public view flip.

🟡 Blocker 4 — No test schema / staging service

There is only the one production Railway service. The joint A+B test can't run end-to-end against a non-prod target — any live /apply is a prod write. An in-process proof exists (22/22 idempotent on the real pull), but a true non-prod E2E either accepts that proof + a gated single prod fire, or first stands up a test schema / second service.

🟡 Blocker 5 — On-demand webhook blocked on n8n API creds

The second entry point (webhook alongside the 22:30 schedule) is designed and approved but build-blocked: N8N_API_URL / N8N_API_KEY don't reload after /clear (the direnv whitelist doesn't cover _Client-Orgs). Root-caused this session to nono profile drift; relaunch reloads them. Does not block the live nightly pull.

⚪ Smaller open items

  • /apply per-day response contract — the multi-day spec marks the statements/report_dates response fields UNKNOWN for the ViskaFront contract; ViskaDB to confirm/enhance (v1.1).
  • Recurring infra gap — viska-pm git push to the Viska org needs a git-credential-helper (App installation token); durable state currently lands via boards / Wiki / devlog instead.
  • Recommended hardening — add a /version route to apply-svc returning the build SHA, so future deploy-confirms need no Railway creds (closes the fingerprint gap in Blocker 1).