Architecture¶
How briskapi works, what the data means and how the private archive protects itself. For usage, see the README.
Components¶
| Component | Where | Role |
|---|---|---|
| Decoder hosts | briskapi/decoder/ |
Run BRiSK's own WASM decoder in Node, without a browser or Chrome DevTools. decoder.cjs replays the public demo, whose assets must match SHA-256 pins in assets.json. brisk.cjs drives a broker's BRiSK decoder (SBI's, e-shiten BRiSK Next's, Matsui's, Monex's or SMBC Nikko's) from an authenticated session, one trading day after another, reading a large selection's quotes with worker threads (workers.cjs). Both emit one JSON batch per frame. |
| Python API | briskapi/ |
Recording (files), Feed (live, background thread, with a worker thread that packages and uploads each finished day), Ticker/Market views and Archive. |
| BRiSK site client | briskapi/brisk.py |
Cookie login (SBI by default, or another broker with site=: e-shiten, matsui, monex, smbcnikko), token boot and the REST data behind Ticker.candles/margin and Market.turnover/lists/events/schedule/watchlist; briskapi.connect(site=...) starts the live host through it (_connect). |
| Archive client | briskapi/cli.py, briskapi/schema.py, briskapi/_parquet.py, briskapi/identity.py |
The brisk CLI, upload consent, your identity, Parquet packaging, upload, verified download, deletion, restore and export. |
| Auction state | rust/brisk_quote_ingest |
Optional Rust tools: brisk_quote_ingest validates batches, keeps per-security state and publishes latest-state files; brisk_recording reconstructs a saved recording. |
| Archive service | archive_service.py, infra/deploy.py |
A Lambda function URL serves each owner's uploads, listings, downloads, deletions and restores; S3-triggered ingest validates and stores and keeps each owner's OHLCV bars in step; DynamoDB holds quotas. |
| Cloud API | query_engine.py, query_service.py, briskapi/_historical.py |
A second Lambda function URL answers Databento-style queries over an owner's recordings and the demo's, with OHLCV bars built from every broker upload; briskapi.Historical and brisk query are its client. The engine and the service run only in the cloud and are not in the wheel. |
| Records | briskapi/records.py |
The Databento-style schemas (FIELDS) and the projections from a recording's tables to them, shared by the cloud API's engine and briskapi.Live, so a record is the same in history and live. |
| Live gateway | briskapi/_gateway.py |
A feed's server on 127.0.0.1 that hands its batches to briskapi.Live sessions on the same computer; brisk live runs one by default. |
demo assets (HTTPS, pinned) ─► decoder.cjs ─┐
broker BRiSK session ────────► brisk.cjs ───┴─► JSON batches ─┬─► briskapi.Feed: live state, callbacks, iterators
│ └─► gateway (127.0.0.1) ─► briskapi.Live: Databento-style records
├─► brisk live: JSON lines
└─► briskapi.record ─► Parquet recording (demo only)
JSON batches ─► Parquet tables ─► zip ─► upload ticket ─► S3 staging ─► verification ─► archive/OWNER/… ─► briskapi.pull
(a replay's market tables: reference/)
archive/OWNER/… + reference/ + bars/OWNER/… (built from the owner's live recordings) ─► query_service ─► briskapi.Historical, brisk query
Data source¶
The account-free input is the public BRiSK Next demo dated 2021-09-27. Its
initial snapshot is at 08:59:59.999955 JST with 4,131 securities. The frame
container has 18,001 frames labelled 09:00:00.000–09:02:59.990, so it provides
one genuine pre-open snapshot and the opening transition, not a pre-open time
series or a full-depth book. Output is labelled source=historical_mock.
Source clocks are preserved. They are the stamps BRiSK's server sends; the decoder
reads no clock of its own (its output is byte-identical under another time zone and
system date). Each issue's snapshot record carries an absolute time in microseconds
after JST midnight, and each later update a delta from that issue's previous update,
which the decoder adds up; a catch-up reply carries deltas only, and an issue with no
snapshot starts at 07:00. About once a minute a time message (microseconds since
07:00 JST) moves the stream clock, time(), which is the latest of those ticks and the
update times. Whether the server's stamps are TSE event times passed through or its
own is not established. Its own looks likelier: an issue's updates are never stamped
less than about 0.3 ms apart, mostly 0.4-0.45 ms, whatever the load and in both 2021
and 2026, like a fixed per-issue processing step; the 09:00 status changes are stamped
61-274 ms after 09:00, each on its own; and an update stamped exactly at the session's
close ends it, so some stamps are conventions. Against that, they have true microsecond
precision, are slightly out of order across issues, and a sweep's trades can share
one. A live session with a snapshot, comparing update times with the server clock in
its heartbeats, could settle it. Until then exchange provenance and exchange delay are
unverified: exchange_delay_ms is always null, and source_timestamp_origin is
brisk_decoder_unverified, a historical name for "as the decoder reports the server's
stamps". special_quote_time_us is an issue's own update time plus 180 s, the same
clock. Live data needs an
authenticated Tachibana BRiSK Next session and validation of the current
protocol: bootstrap, master/snapshot, WebSocket handshake, keepalives and
catch-up. The demo's protocol version is 16000. The recorder
documentation compares this with pybrisk's
browser-based live reader and defines every field and timing measurement.
Batch stream¶
Every source produces the same stream:
bootstrap: the security master and every selected security's initial quote, plus transport and clock provenance.history(only after a snapshot that restores trades, as a live session's started mid-day can): those trades, up to 50,000 per batch, right after the bootstrap and at its clock. The bootstrap's volumes already count them.quotes: one batch per frame with the quotes that changed in it and its trades. Empty batches are kept, so sequence continuity and feed health stay separate from a quiet security's last update.end: frame and update counts, written only after a clean finish.
That is one trading day. A demo replay is one such stream; a broker's live host emits one per day, back to back, each numbered from its own bootstrap (see Trading days).
Sequence gaps, clock regressions, malformed input and interruption invalidate a stream in the Rust state and in archive validation. The Python feed checks sequence continuity within each day and a clean end of each.
Recording time queries filter each quote by its own source_time_us, including
quotes in batches whose clock is later than the query. Batch clocks are upper
bounds on contained quote times, so stopping at the first later batch would miss
valid snapshots and deltas. Market.summary() uses the first and last batch
clocks, including empty batches and the end marker; recordings cache that range
when no manifest is available, while feeds track it under the state lock.
Recording format¶
For users, docs/data-format.md describes every column of recordings and of cloud API records; this section is the internals.
A recording is a directory of four Parquet tables (briskapi/_parquet.py), laid out
like a NautilusTrader v2 catalog's data/. Each table is v2's own schema with BRiSK's
fields added after it. Rows carry v2's identifier column (CODE.XTKS), as v2
returns multi-instrument queries, instead of one directory per instrument: a
directory per security would make the complete demo 8,262 small files and about three
times the size. Reading gives back the decoder's batches exactly, so validation and
reference fingerprints are the same for every format.
| File | Rows | Columns |
|---|---|---|
data/order_book_depths.parquet |
One per quote update, sorted by issue, then batch | v2 OrderBookDepth: bids, asks (up to ten levels each, best first: price, size, count, order_id 0), flags (16, plus 32 on the bootstrap snapshot), sequence (the frame number), ts_event (source time), ts_init (the same). Then identifier, issue_id, seq (the batch), max_frame, the prices (last_price, open_price, bid_price, ask_price, indicative_price, closing_indicative_price, indicative_open_price, reference_price; null for the vendor's zero), the counts and enums (indicative_volume, indicative_imbalance_side, closing_indicative_volume, closing_indicative_imbalance_side, quote_flag, quote_side, volume, issue_status, market and closing market buy/sell quantities, bid_quantity, bid_orders, ask_quantity, ask_orders, and the board totals bid_total_quantity/_orders, ask_total_… and their bid_closing_total_…/ask_closing_total_…), special_quote_time, turnover, and the closing-condition orders' books closing_bids and closing_asks (shaped like bids and asks) |
data/trades.parquet |
One per execution, sorted by issue, then in the decoder's order | v2 TradeTick: price, size (shares), aggressor_side (BUY for the decoder's type 1, SELL for 2, NO_AGGRESSOR otherwise), trade_id (the issue's frame number and the trade's place in it), ts_event/ts_init (the trade's time). Then identifier, issue_id, seq (the batch), frame and side (the raw type) |
data/equity.parquet |
One per security, by issue | v2 Equity (id, raw_symbol, currency JPY, price_precision 1, price_increment 0.1, lot_size, max_price/min_price from the daily limits, ts_event/ts_init at JST midnight; info, JSON in v2, is a null string), then issue_id, tick_type, issue_type, name, base_price |
data/custom/BriskBatch.parquet |
One per batch | seq, kind (bootstrap, history, quotes or end), received_unix_ms, decode_ns, replay_lateness_ms, skipped_issues, frames, quote_updates, replay_wall_ms; on the bootstrap row source, trading_date, input_transport (canonical JSON), source_timestamp_origin, exchange_delay_ms, market_issue_count; v2 custom data's type, ts_event and ts_init |
Prices and level sizes are decimal128(38, 16) as in v2; times are UTC
timestamp[ns]. ts_init repeats the source time rather than a local clock, since a
demo replay's receipt clock is years after its market time; receipt times stay exact
in BriskBatch.received_unix_ms. Tables are zstd-compressed in row groups of 65,536
rows, so one security's history reads only its row groups. The complete demo replay
(735,382 updates and 92,263 trades) is 41 MB, against 965 MB of JSONL.
brisk export DIR --nautilus CATALOG writes v2's layout: equity, order_book_depths
and trades per instrument, readable by v2's ParquetDataCatalog, and the rest of each
quote as custom data BriskQuote (int64 columns in vendor units, so a v2
customdataclass can declare them, and the closing-condition books as JSON text).
--jsonl FILE writes canonical batches.
A broker's recording is a trading session of the whole market, some 25 million updates, so reading
one never holds it whole in memory where it can be avoided: one security's quotes and trades read
only its row groups, state() (and so Market's views of every security at a time) reads a row
group at a time, Market.trades() reads the trades table alone, and the Nautilus export writes an
instrument at a time. Only batch order (batches(), lines(), --jsonl) needs the recording in
memory, tens of gigabytes for a session, as its rows are stored by security.
Live feed¶
briskapi.Feed reads decoder output on a background thread and applies each batch
to its state under a lock. A new listener takes its snapshot and registers in one
locked step, so it can't miss or duplicate an update. Iterators use a bounded
queue (10,000 items) and callbacks run on the feed thread, so a slow consumer
slows the decoder instead of dropping updates. An abandoned iterator never blocks
the feed. An exception in a callback stops the feed and is re-raised by wait().
A feed keeps current state only, unless history=True retains every update per
security.
A feed records one trading day at a time. It splits the stream into days, each a
bootstrap ... end numbered from its own bootstrap: the sequence check admits a new
bootstrap only after an end, and at it the feed's state resets (the quotes, and with
history=True the retained updates and trades) and its timing statistics start again.
A demo replay is one day; a broker's live host (see Trading days) goes
on to the next.
When uploads are enabled, output is given or the feed has a gateway, the feed also writes
each day to a spool as it arrives (briskapi/_spool.py; a gateway replays from it, and a day
kept only for that is not packaged). The spool is the recording's own Parquet tables, a part
at a time, under ~/.local/share/brisk/spool/segment-ID/: the equity table with the
bootstrap, then parts/NNNNNN/ with the depths, trades and batch tables of the batches since
the last part. A part closes every PART_SECONDS (30), or after PART_BYTES (64 MiB) of decoder
output, and at the segment's end. Its lines are parsed, made canonical as package() makes them
(so no connection diagnostic reaches the disk) and turned into columns from the moment it closes
(part_columns; without the GIL in slices, on up to _parquet._THREADS threads: a third of the
cores, at most four, as the feed and its host come first); a writer thread of its own then writes the
parts in order (write_part, which numbers trades on from the part before), fsyncs each and renames
it into place, so a crash loses at most the part still open. A day's
parts take about the disk of its recording; its decoded JSON, which the feed used to keep in a
temporary file, takes some twenty times more. At the day's end the feed hands the spool to a
worker thread (a ThreadPoolExecutor of one, so days finish in order) and reads on. The worker packages
the day, keeps it in output as SOURCE_YYYYMMDD_FROM-TO (schema.local_name; FROM and
TO are the day's first and last source clock, JST HHMMSS; a folder already there is left
alone, with a warning), uploads it to the owner's private archive and uploads its
timing summary, as chosen, then appends its result (trading_date, name, path,
uploaded, timing_uploaded) to feed.recordings, calls on_day(result) and deletes
the spool. Packaging reads the parts back (load_parts: each part's tables decoded, joined
and put in the canonical order) and writes the same bytes as the day's batches at once would;
tests/test_spool.py checks it. Its manifest's summary comes from those columns
(Columns.summary) rather than from the archive's checks run here, which took the demo's three
minutes 35 seconds and would take a day most of an hour: the archive runs them on every upload, and
an upload it rejects is kept like a failed one.
A broker's day is shipped as it runs, in two pieces (Spool.ship): a whole-market day has some 50
million quote updates, more than a computer should package, or the archive check, at once. The morning
session's piece ships as soon as a part closes after noon (LUNCH_US, the lunch break), the afternoon's
at the day's end; no piece holds more than MAX_PIECE_ROWS (60 million). After each part is written
the spool tells the feed's worker, which decides from the parts' small columns (_parquet.part_index).
Each piece is a recording of its own: the afternoon's (_parquet.write_piece with a cut) starts with a
bootstrap like the day's, at the clock of the batch before it, holding each issue's last quote so far
(an issue with none is skipped), then its batches numbered on from it and the day's end with its own
count. The two meet at a clock, and the cloud API reads them as one: its bars merge their clock ranges,
and the quotes a bootstrap repeats carry the frame numbers already seen, so its deduplication drops
them. A piece is written a range of issues at a time (each part's depths hold a row group per
PART_ISSUES issue IDs, and a range gathers about PIECE_ROWS rows from every part), in the row
groups writing it at once would make, so the bytes are the same and no session is ever whole in memory;
its zip is written and hashed on disk (pack_to). The worker keeps and uploads each piece as it would a
day. shipped.json counts the parts shipped, so a crashed session's spool is finished from there; where
the morning left the book is rebuilt from its parts a part at a time. A demo's replay is one recording,
as its reference requires.
A packaging or upload failure is a warning and a result that
says so, never a failed feed, and the day is then kept in ~/.local/share/brisk/recordings
(its package, for brisk upload to retry, or its tables); neither is an on_day callback that
raises. brisk live --output
DIR prints each result as {"day": ...} on stderr. Broker feeds do the
same with their market recording and also accumulate timing statistics
(briskapi/timing.py). Uploads are on by default
(cli.consented): with no saved choice the CLI asks once, on a terminal, before the first
session (Enter accepts), and a run that can't ask (the Python API, a CLI without a
terminal) uploads by default after a once-per-process notice, without saving a choice. upload_market_data=False
(--no-upload-market-data) keeps a broker session's market recording local;
BRISK_UPLOAD=0 or upload=False (--no-upload) disables both uploads. Only a segment that
reached its end, a day or the part of one before a lost connection or a stop (see
Trading days), is packaged and uploaded. close() stops a broker feed by asking
its host to end the running day (SIGTERM, which brisk live also gets from Ctrl-C, kill or a
shutdown) and waits up to 30 s for the host to exit, its end written and read, before killing it;
a demo replay is killed at once (a partial replay is never uploaded). A broker segment that is
still unfinished when the feed closes or fails (a host that crashed, was killed or did not end in
time) is repaired: _parquet.repair_end appends the end its host never wrote, and the feed
finishes it like a day (_finish_segment(repair=True)), its result marked repaired. The repaired
end's quote_updates counts the segment's quote updates exactly, as the host does; the host's
frame count and wall time died with it, so frames is the least the archive's check allows (one per
batch after the bootstrap) and replay_wall_ms the span of the batches' received_unix_ms. A demo's
segment is dropped, being the same for everyone. Each spool records its session's choices
(intent: upload, output) in owner.json; a spool a crash left behind (its owner process gone) is
finished the same way by the next feed to start (_spool.leftovers, Feed._recover): uploaded if
its session uploaded and uploads are still on, kept in its session's output, and dropped if that
session kept nothing (a gateway's spool only). A segment that fails even so is kept in
~/.local/share/brisk/recordings. wait() and close() return after the days already finished
are kept and uploaded.
Live gateway¶
A feed started with gateway=True (brisk live unless --no-gateway) serves its batches
to briskapi.Live sessions in other processes on the same computer. Only the feed signs in
to the broker; a session needs the gateway's key and nothing else.
- Reach.
Gatewaylistens on 127.0.0.1 (an ephemeral port) and writes its port and a random key (secrets.token_urlsafe(32)) to~/.config/brisk/gateway/DATASET.json, created owner-only (0600) and replaced atomically.Livereads that file. A session's first line must carry the key (compared in constant time), the protocol version (1) and the gateway's dataset, or it is answered with an error and closed. A file whose gateway no longer answers (a killed feed) is replaced by the next one; while one answers, a second feed for the same dataset serves nothing, with a warning. Closing the feed removes the file. A broker feed isXTKS.BRISK, the demoXTKS.BRISKDEMO, as in the cloud API. - Protocol. Newline-delimited JSON. After
{"gateway": "session"}acknowledges the key, the session may send subscriptions at any time:{"subscribe": [ids], "symbols", "replay", "snapshot"}(start()sends those made before it as one). Each is answered in the stream:{"gateway": "subscribed", "id"}, with the day's bootstrap (master, without quotes and trades) if the session lacks it and, if asked, those symbols' latest quotes; then, if asked and the day is running, the day so far and{"gateway": "live", "id"}. Every batch follows as it arrives, and last{"gateway": "closed", "status", "error"}when the feed ends. Batches are the decoder's own lines: unchanged once a session asked for all symbols, otherwise rewritten with only the quotes and trades of the symbols subscribed so far (the master whole; a batch left empty is skipped). - No batch missed or repeated.
Feed._runadds each line to the day's spool, applies it, then callsGateway.publish, which updates the gateway's own bootstrap, latest quotes and the count of lines published, and queues the batch for every session, all under one lock. A subscription widens the session's symbols, takes the bootstrap, the latest quotes and a replay of the spool's first that-many lines under that lock (Spool.replay: the closed parts, read back and re-encoded, then the lines not yet written, as the decoder wrote them), and queues its answer (written out, with the replay, on the session's thread). So its answer comes after every batch the session got without its symbols and before every batch with them, and a replay meets the live batches exactly; batches that arrive meanwhile wait behind it. At a day's end the gateway stops offering replays: the worker packages and deletes the spool, and a replay already under way keeps it until it is read. - The fast lane. A broker feed's recording reads every quote of the whole market, frame by
frame, and at the opening auction it can fall behind the stream. The host (with
BRISK_LANES=1, which the feed sets when it serves a gateway) also runs a pool of its own for the fast lane, a quarter of its quote workers (at least one). Its decoders are fed every frame, read the quotes of the symbols Live sessions are on the lane for, as each frame arrives, and write them at once as out-of-band lines ("lane": "fast") of the frame's batch number, ahead of the recording's batch. Lines come in one stream, so the feed reads them in order with the recording's; the feed passes them toGateway.lane, never to the recording. The gateway tells the host on its stdin which symbols the lane reads ({"fast": [codes]}). A symbol added while the day runs is read once at once ("type": "join", its quote after the lane's last frame), and the subscription waits for that answer, so its snapshot and its updates meet. Before each session's bootstrap the host says whether it has the lane, for how many symbols (500 per worker) and which it reads ("type": "lanes"). A Live session is on the lane from its first subscription if that asks for no replay and, with the lane's other sessions, for no more symbols than it takes;ALL_SYMBOLSnever is. Until the host has said, a session that would fit gets the recording's batches; at that word (before any batch of the session) it moves. A session on the lane gets the recording's bootstrap, history and end, but its quotes from the lane only. One whose later subscription does not fit leaves it for the recording's batches: up to the last batch the lane brought it, those batches without the symbols it had there (fast_until,old_codes), and its new symbols' snapshot from the recording's state, so it misses nothing and gets nothing twice. A symbol's updates come from one lane at a time, in frame order, so trade IDs (an issue's frame and the trade's place in it) and bars are as the cloud's. Tested from the host (each fast line is the recording batch's quotes and trades of those symbols, a join the recording's state) through the gateway (records equal to the recording's, across a join and a move back) to a live host behind the feed. - Slow sessions. Each session has its own sending thread and queue. The feed never waits
for one: a session more than
MAX_BEHIND(1 GiB) behind has its queue dropped, is sent an error and is disconnected (after 10 seconds if it still reads nothing), so a stalled program cannot slow the feed or its recording. At most 32 sessions at once. - Records on the client.
Liveprojects each batch itself, so the feed process does no work per session beyond queueing (and, for named symbols, rewriting) lines. It builds a batch's depths and trades tables with_parquet.stream_tables(the recording's own row encoding, in arrival order) and the day's equity table once, then appliesbriskapi.records' projections: the same code the cloud API runs over stored tables, which is why a live record equals the stored one.ts_recvis the batch'sreceived_unix_msforXTKS.BRISKandts_eventfor the demo, as in the cloud. Trade IDs (FRAME-PLACE) carry each issue's last frame and place across batches (Columns.trade_ids(carry)), so a replay from the day's start numbers them as the stored recording does. A session that named symbols skips, unparsed, replayed lines that mention none of its codes. Records come per batch, sorted within it as the cloud API sorts (ts_event,instrument_id,sequence); across batches, in arrival order.startfilters onts_event; definitions (stamped at JST midnight) are given when a subscription starts, or by a replayed bootstrap, and at each new day. A snapshot is projected as a bootstrap batch (seq0), sombp-10flags it 32. A replay goes to its own subscriptions only, projected against a day of its own (fresh trade IDs), while the others wait for the live batches behind it. - Bars. Each bar subscription aggregates its trades per (code, bar start) as the cloud's
build_barsdoes: the open is the first trade by (ts_event, frame, place in the frame), the close the last, with high, low and summed size. A bar goes out once BRiSK's clock reaches its end, with no allowance for trades stamped earlier than the clock: a trade arriving after its own bar has ended (by the clock the bars were last given to) goes into the next bar still open, so its volume counts and no bar for a past interval goes out late. The cloud places such a trade by its own time, so the two can differ there. That clock is the latest batch's, or, between batches, this computer's less the feed's delay:_Latencykeeps, for each live batch, when the session took it in less BRiSK's stamp (network, the gateway's hop and any clock difference) and allows for the largest of the last 10 seconds, andLive._tickwakes when the next bar is due. Without that, a session that named a few symbols, which receives only their batches, would hold a quiet symbol's bar until its next update. The tick pauses during a replay, whose live batches are still queued. Bars obey the cloud's coverage rule (records.session_pieces, shared): a bar is given only if its whole span within the trading sessions lies between where the session's trades begin (the day's bootstrap when it saw the day from the start, by replay or a new day; else its first batch) and the clock, so a bar is never partial; a replay of the demo, whose cloud bars have no such rule, gives every bar. The day'sendbatch gives what remains. - Delivery. Callbacks and streams run on the session's thread; a callback's exception goes
to its
exception_callbackor a warning. Iteration uses a bounded queue (10,000 records) that blocks the session's reader, and through TCP the gateway's queue, never the feed.stop()shuts the socket down. A session that loses its connection without the closing line ends with an error, as does one whose feed failed.
Timing reports¶
A timing report is uploaded even when a broker session keeps its market
recording local. A report (brisk-timing-v2) holds p50/p90/p99/max of decode time, feed-clock age
at receipt and frame spacing, a stall count (gaps over one second), the frame count,
trading date, first/last JST minute and client version. It covers one day: a session
that records several uploads one report per day, each when its day ends, and a day cut
short by closing the feed gets one for the part recorded.
The API's timing action takes the report. The service checks exact fields, bounds and
microsecond precision, monotonic percentiles, at least 100 frames and a trading date within
the last 30 days; rate limits it (six per IP address per hour, 600 in total); and writes its
own canonical JSON to timing/OWNER/YYYYMMDD/SHA256.json, so identical reports deduplicate.
A report is at most 2 KB. briskapi.Archive().timing() reads the owner's back.
Broker BRiSK¶
The endpoint sequence was learned from pybrisk: session cookies authenticate
/api/frontend/boot, which returns a bearer token; /api/app/boot then gives
the trading date, series, schedule, WebSocket URL and master/snapshot hashes.
Requests are rate limited and never follow redirects, because a redirect means
an expired session and following it would forward credentials. briskapi.connect(site=...)
asks /api/frontend/boot before it starts the live host, so an expired session
is one SessionExpiredError line and Node never starts. Cookies given as JSON
(the BRISK_*_COOKIES variables or a saved cookie file) that don't parse are
the same error, naming the variable or file but never the value.
The live host (briskapi/decoder/brisk.cjs) uses the same session to fetch the
master and snapshot, finds the site's decoder through the app's own bundles (it is
served only to logged-in sessions and changes with the broker's releases, so it can't be
pinned), checks that it exports every function the host calls, and decodes
WebSocket frames with protocol version 18000. Cookies reach it through the
environment (the site's own variable, such as BRISK_SBI_COOKIES or BRISK_ESHITEN_COOKIES;
BRISK_SITE names the site), never the command line. One table names the brokers in each place,
SITES in brisk.py and in brisk.cjs, with their sources and transports in schema.py
(tests/test_brisk.py checks they agree): address, label, cookie variable, source and
transport kind.
The page may name its bundles by absolute or relative path, and a bundle names the
loader either as a literal path or as a template with an exchange variant,
./assets/wasm${r}/fita.<hash>.js (r is -fukuoka for the Fukuoka exchange; only
the Tokyo build is used), in which case the .wasm name follows the loader's, as
Emscripten names them. Bundles on another host are never fetched. The boot's
ws_url is relative: as in the browser, it is resolved against the frontend boot's
api_endpoint (https://api.brisk.jp on e-shiten and in pybrisk's SBI sample), or
the site when there is none. A stream outside brisk.jp is refused. Cookies go only
to the site's own host; elsewhere the session in ws_url authenticates the stream,
which is opened with the site as Origin.
The host follows the connect sequence in the vendor client (the public Next demo
bundle) and in pybrisk's SBI research (engineio.cjs and brisk.cjs):
- The WASM is started with its six callbacks (
send,updateNumber,heartbeat,basePrice,authError,marketFinished) using the vendor's signatures (plus the session schedule on the 2026 builds, above).sendbytes are the WASM's replies to the server's pings, written to the socket as raw binary frames: in pybrisk's recording of SBI's stream the server sends a 9-byte ping every 5 seconds and the browser echoes each one back unchanged. The vendor client has no ping timer of its own. - The link detects the server's dialect from its first message. The Next client
uses a plain binary WebSocket, and SBI's stream is recorded as one too. It is
/realtime/0on the API host the frontend boot names (api.brisk.jp); pybrisk's notes givesbi.brisk.jp, but the boot in the same capture already namedapi.brisk.jp, and the site host now answers/realtime/0with 404. The host asks for the WebSocket subprotocolflex, as the vendor client does (new WebSocket(url, "flex")). If a server opens with an Engine.IO packet instead, the host falls back to joining a Socket.IO namespace (/v2/user) with a query, sending astartLiveevent and answering Engine.IO keepalives (clients ping on version 3, servers on version 4). That fallback comes from a separate Socket.IO connection in the same capture, which upstream's notes took for the stream's wrapper: its frames are numbered apart from the binary ones, itsurlparameter names pages of SBI's own website, and its keys don't have the shape of BRiSK's values. It most likely belongs to another service on SBI's site, so its parameters are unverified. - Frames go to the WASM immediately. When the first frame numbers arrive, the
host compares them with the snapshot's. Issues whose snapshot is behind are
caught up with
POST /api/stocks_update/{series}(up to five retries, never for an expired session or anx-error-reason: too-oldreply) and the reply is pushed into the WASM before quote tracing starts. Frames that arrive meanwhile are not lost: the decoder holds them until_apiRecieved, then applies them, skipping updates the snapshot already has. The decoder applies whatever reply it is given, so afterwards each caught-up issue must be exactly at the stream's first frame and every other issue where it was; a reply pushed twice, or a repeated or stray update, stops the session (on SBI's March data the real reply passes and a second push of it is refused). - The server heartbeat, decoded by the WASM, is watched: no beat for 10 s after the
first one is a connection failure, as in the vendor client. Any frame arriving also
counts: the vendor client decodes each frame on arrival, but the host decodes in
order with its output, and in the opening burst frames (heartbeats among them) can
wait behind the output for longer than 10 s, which once ended the session at
09:00:07 although the link was alive. A second rule is the
vendor's too: during trading hours (08:00-15:00 JST, not 11:30-12:10) the
decoder's data time may lag the server clock, taken from the heartbeat, by at
most 90 s. The WASM's
authErroris the single-session rule (one WebSocket per user), andmarketFinishedmakes an abnormal close a normal end. - The decoder protocol comes from the boot response's
flex_version(an explicit option wins; 18000 if the server says nothing). The master is checked as soon as it is read (below). The boot response'sexceptional_sqandbase_pricesare pushed into the WASM after that and before the snapshot, as the vendor client does, and the master's limits are re-read after every frame in case the decoder changes them (a pushed base price moves the record's limits; in both builds its base price field keeps the loaded value). A special quote names its issue differently by build: the demo's_pushSQtakes the security code as a number and looks it up, while the 2026 builds take the issue index and read the code (167Atoo) from their own record. Given a code, a 2026 build files the quote under whichever issue has that index (1301's under 8283), or rejects it, which it reports only on stderr ("Invalid Issue Code"). The host sends the index at 18000 and the code at 16000. A special quote whose code is not in the master is not sent and is listed inboot_applied.skipped_exceptional_sq. If the decoder still rejects one, the session stops: the build would be naming issues in yet another way. On SBI's March build, boot special quotes for 167A, 1301, 25935 and 7203 each land on their own issue and no other (tools/brisk_mock/sbi_master_layout.py).
The ping loop is the WASM's own. In the real decoder, market and sync frames never
trigger a ping; each heartbeat frame (0xF0, 0xF2, 0xF4 or 0xF6 and an 8-byte
little-endian clock in nanoseconds) triggers one reply: the same nine bytes, echoed.
The loop therefore sustains itself once the server starts it, and the host only has to
forward the bytes.
The decoder reads its input as one byte stream, so frame boundaries don't matter to it;
_pushWs returns nothing, but writes the latest source time of the updates it applied
and how many it applied (none for pings, clock messages and frames held before
_apiRecieved). After begin(), every frame of stock updates (odd first byte) applies
at least one, through the demo's recorded day and SBI's recording. One that applies none
leaves its bytes held for the next frame, and if the frames after it apply none either,
the parser has lost the messages' boundaries: it decodes nothing more, raises no error,
calls nothing back and answers no ping. The host then stops the session at once, naming
the frame after which it happened, rather than waiting for the heartbeat rule. Decoder
failures are made readable too: a parse error (an unknown message header, an HTML page
pushed as data) escapes the WASM as a bare number, the address of a C++ exception whose
message the host reads, and a WebAssembly trap leaves the decoder's stack 16 bytes down,
which the host restores; either way the error names the frame or the issue.
The stock view and the master are read at version-specific positions. Protocol
16000 (the demo) is the vendor client's own layout. For 18000 (the brokers' BRiSK) the stock
view has the same fields in the same order with the leading word, the numeric
security code, dropped (the code is text there), so every index is one lower.
SBI's decoder of March 2026 confirms it: its _getStockView writes the demo's fields
in the demo's order, and with the demo's snapshot loaded (below) every word of all
4,131 issues' views equals the demo decoder's word one place on. Any other protocol
version is refused rather than read at guessed offsets.
What some recorded fields hold, read from both builds and checked on the demo's data
(the values stay the vendor's raw ones). quote_flag 2 is a special quote, the only flag
that starts the decoder's 180 s timer (special_quote_time_us is its start plus 180 s);
3 is priced like it without the timer; 1, a mark with no effect on prices, occurs only at
16000 (SBI's build drops it, so the same frames give 0 there). quote_side is 0 for the
buy side and 1 for the sell side, and means nothing when the flag is 0.
reference_price10 (named auction_reference_price10 before policy version 5) is the
price the decoder's indicative matching starts from:
the special quote's price while there is one, otherwise the last price, otherwise the base
price (at 18000 a price the server sends with the issue's status comes first, and after
the morning close the morning's last price holds until the afternoon trades). In
continuous trading it therefore equals last_price10. indicative_imbalance_side
(formerly indicative_side), and closing_indicative_imbalance_side for the closing
auction, are 1 when the sell quantity at that indicative price exceeds the buy quantity
(or nothing matches), else 0. turnover10 is price times shares in tenths of a yen; its
bars' turnovers sum to it and its VWAP lies between the day's low and high on all 3,774
traded demo issues. issue_status takes the same values in both builds (1 pre-open, 2
the morning session, 5 the afternoon's; 3, 4, 6 and 7 are the lunch break, the
afternoon pre-open, the closing auction and the close as far as the code shows): the
demo's from _getPortfolio, SBI's from its view, less 8.
The book comes from _getItaRows(id, issue, first row, rows, price rows, market row, OVER,
UNDER, light), which writes 204-byte rows: price (tenths of a yen) at byte 0, then buy,
closing-buy, sell and closing-sell groups of quantity (u64) and order count (u64, 16 bytes
on) at 20, 64, 112 and 156. Row indices step one tick from the lower limit to the upper
(_getTickPrice10ToIndex(id, tick type, price); the tick type is not the issue); OVER and
UNDER sum every row above and below the window read, and the market row holds the market
orders and, in its closing groups, the market-on-close orders. Its layout is the same in
both builds, which return byte-identical books for every issue of the demo's snapshot. A
quote keeps each side's ten best prices that hold orders, and the quantity and order count
at its own bid and ask.
_getItaRows is a rendering helper for the board on screen, and costly: it looks up the issue's
board object, rebuilds it if a frame changed it (0.3-0.5 µs), then walks every tick of the
ladder for OVER and UNDER, converting each to a price through the issue's tick bands and adding
notional columns (quantity × price) on the way. A call costs the same for 1 row or 400, about
10 µs on average and 28 µs on Toyota's 3,521 ticks. So the decoder reads books itself
(BoardReader) and uses _getItaRows only to check it, or in its place when it cannot be used:
- Where the ladder lies. Each board holds its ladder in a
std::dequeof 72-byte levels, 56 to a block, one per tick from a first tick index the board keeps. A level holds the quantities (f64) of its bids and closing-condition bids at +8 and +16, their order counts (u32) at +24 and +28, and the same for the asks at +40, +48, +56 and +60; the market orders are a level of their own in the board, just before the deque. The builds lay the board out differently (the demo's deque at +92, SBI's of March 2026 at +100), soboardCallsreads_getItaRows' code for what it calls: the board lookup (a slot of the function table and a registry address, called throughdynCall_iiii), the flag that marks a board rebuilt, and the rebuild (dynCall_vi). Thenlocate()finds the deque's block map, first slot, length and first tick index in the board object, and its market level, by matching issues' levels to_getItaRows' rows. - What it reads. The board totals are summed by a kernel of ours,
board.wat(SIMD, assembled intodecoder.cjs), which shares the decoder's memory:create()makes the memory the glue would (its import's size) and hands it over. Each side's ten levels are read from the best end of the ladder, and their prices from_getTickPrice10ToIndex(the lowest price at that index, remembered per tick type). A quantity_getItaRowscould not convert is a trap here too. - Checks. At start every issue's book must equal
_getItaRows'; every 4,096th book after that is checked again; anything amiss, or a build whose code does not read as expected, and the decoder reads books through_getItaRowsfor the rest of its life (the host's--trace-protocolsays which). With no orders on any board yet (before the pre-open), it is looked for again as books are read.
Both builds' books are the same both ways: the demo's whole replay (its reference digests), every
book through the demo's opening auction, and the 2026 build with the demo's snapshot against the
demo decoder through _getItaRows. The kernel also finds where each side's orders begin, so the levels are read
from the best price outward. A quote now costs about 8 µs (70 µs when the
ladder was walked 32 rows at a time).
Trades come from the decoder's QR records, which it keeps on the host side: for each
execution it calls the imported _qr_emplace(stock, type, time, price10, lots, lot, frame)
(stock is the issue's _getStockPointer), reads its last price back through
_qr_lastprice10 and withdraws a record with _qr_pop_back. The exported _getQR is a
stub that returns 0 in both builds, and _getTrace only lists changed issues. On the
demo's data every issue's volume and turnover change by exactly its records' shares (lots
times lot) and price times shares, frame by frame. Type 1 records lie at or above the
prevailing ask (93%) and type 2 at or below the bid (89%); type 0 is the first trade of
1,508 issues around 09:00:00.09 and 22 later prints, so it looks like an auction
execution. The record's time is the same clock as the quotes', 0-30 ms (median 2.8 ms)
before the quote that shows it. The host takes the records after each frame; one
withdrawn after it was emitted ends the session (none is withdrawn on the demo). The decoder
reads back only an issue's count of records (_qr_size) and the last one's price, so an issue
keeps the prices of all its records but the records themselves only while one could still be
withdrawn from those not yet taken: after the open a snapshot restores the day's trades so far
(about 4.6 million on 2026-10-12), which kept whole took some 460 MB in every decoder (now 51
MB), and quote workers let the restored trades go. They are appended one at a time (spread
into one call, millions overflowed the stack). Records a
snapshot restores (a live session's could hold the whole day so far) come after the
bootstrap in history batches of at most 50,000, by issue; the snapshot's quotes already
count them, so they stay out of the volume check.
SBI's 2026 build has the same QR imports, called from the same two routines (one per
execution, one with the containers' set-up), but its trades are unverified: the offline
capture comes without a snapshot, and without one the build sets up no QR containers.
An issue whose quote traps the decoder (its state for that issue is inconsistent: see
below) is skipped, listed in the batch's skipped_issues, and not read again until its
frame number moves, because read again at the same frame the decoder returns stale
derived fields instead of trapping. A bootstrap's quotes and skipped issues together cover
its master; a skipped issue is quoted once it updates and reads.
The master is read from the decoder's own records (_getStockMaster), as the vendor
client does, at the version's layout (MASTER_LAYOUT in decoder.cjs). 16000's is the
vendor client's: 144 bytes, the code a number. In 18000 the code is eight bytes of text,
which must hold codes such as 167A (350 of 4,445 in SBI's master of 2026-03-11), and
every later field is four bytes on: 148 bytes. That layout was found by running SBI's
decoder of March 2026 and the master file it served, both from pybrisk's research, and
matching the records against the file: all 4,445 agree on code, base price, limits, lot
size and issue type, and the names and tick types (0, 1 and 3) are the decoder's own.
tools/brisk_mock/sbi_master_layout.py repeats this from pinned downloads.
The master file the decoder loads checks every master read (checkMasterFile). It holds
fixed 180-byte records in issue order; pybrisk documents the record size, the binary header
(a word of unknown use, then base price, upper and lower limit in tenths of a yen) and the
code's position. The 2021 demo's file has the same records after a 444-byte preamble, and
reading it equals the demo decoder's own master on all 4,131 issues, which places issue
type at byte 40, the code at 50 (five wide, for some preferred shares) and the lot size
right-aligned up to 139. Records are counted back from the end with the decoder's issue
count. The tick type is bits 25-26 of the record's first word, whose low byte is the
master record's message tag (0x18); it equals the decoder's on all 4,131 demo and
4,445 SBI issues. If any issue disagrees on those seven fields, the session stops: a build whose
record layout has changed, or a file that does not line up with the decoder's issues,
can't pair quotes with the wrong codes. Names are not compared, because the file's
differ (the decoder keeps U-NEXT whole but drops a market prefix such as
M-). The master is also refused if it
does not look like a market (security codes, base prices inside their limits, plausible
lot sizes, names).
The 2026 decoders (SBI's of March 2026, and the build both sites served from October)
take an eighth _initialize argument that the demo's doesn't: the session schedule,
made by _createSchedule from the boot's schedule_info (seven session times in
microseconds after midnight, then sq_jump_interval, in the boot's order). The host
passes it whenever the decoder exports _createSchedule; the demo's build starts as
before. The boot gives sq_jump_interval in seconds (180), but the decoder adds it to a
special quote's time in microseconds, where the demo's build has a fixed 180,000,000, so
the host passes it in microseconds and refuses a value outside 1-3,600 s. Passed as 180,
a special quote's next time came 180 µs after the quote instead of three minutes (no
broker session had streamed yet, so no recording carries such times). In microseconds, the first 3,000 frames of the
demo's stream replayed through the March build under 2021's hours give the demo decoder's
special quote time on all 141,281 updates (4,738 of them with one). _createSchedule stores the eight values as integers and counts the bars as
(morning close - open) / 5 min + (afternoon close - open) / 5 min; every stock keeps a
pointer to the schedule, and a trade's time is turned into its bar through it. Without
a schedule these decoders keep no price bars (_ohlcLength is 0), can't return a stock
view, and fail on a snapshot with deque (fita.cc:1133). A snapshot's bars must match
the schedule's count: anything else is refused with Invalid unserialize. The demo's
snapshot (60 bars: TSE closed at 15:00 in 2021) loads into both 2026 builds only under
2021's hours, and then every one of its 4,131 quotes equals the demo decoder's, field
for field; under 2026's hours (66 bars) it is refused.
The catch-up request is the vendor client's, as pybrisk's capture recorded it:
POST /api/stocks_update/<series>?date=<date> with the JSON object
{"issue_ids": [...], "update_numbers_from": [...], "update_numbers_to": [...]}, one
entry per issue whose snapshot is behind the stream. The binary reply is pushed into
the decoder as it comes.
What is verified: the decoder SBI served in pybrisk's March 2026 capture exports
the demo's interface apart from _getPortfolio, and it initializes under Node with
protocol 18000. (Its stock view carries the issue status one word after the demo's
last, as the status plus 8; minus 8 it equals the demo's _getPortfolio status on all
731,251 views of the demo's stream replayed through both builds. Broker quotes record it
from there.) Offline, it loads
that capture's master, schedule and catch-up reply: the catch-up advances each of its
1,552 issues by exactly the updates requested, briskapi's request for them is the one the
vendor client sent, and 1,549 stock views read back the decoder's frame numbers; started
without the schedule, none does. The other three trap: with no snapshot under the
catch-up, an update takes an order quantity below zero, and the decoder traps converting
it to an unsigned integer (float unrepresentable in integer range). Full quotes, which
also read the order book, trap on 292 of the 1,552. With the demo's snapshot under it, no
view or quote traps; a live session reaches this only with a missing snapshot or a lost,
repeated or reordered update, and the issue is then skipped (above). Given the demo's assets, the same tool
also loads the demo's snapshot as above (tools/brisk_mock/sbi_master_layout.py). The
October build did the same in a one-off check. The demo
decoder is tested against the vendor client's own indices on real data, and its
heartbeat, ping reply and callbacks through its function table; the ping loop and
the whole flow run offline against fake servers (plain, Engine.IO 3 and 4) using it.
Replayed through SBI's March decoder, the archive's copy of pybrisk's SBI stream
(2026-03-11, the sample_data wire capture: 30,505 messages from 12:12:40 JST) starts
partway into the connection, without the opening frame-number message, so the decoder
applies no stock update from it on its own. Given one made in the demo's layout (0x88,
a 16-bit issue count, a 32-bit update number per issue, here all 0; it is the only
opening message either build knows), the recording stalls the decoder: every 40 s, after
every eighth ping, it holds the same 15-byte frame (99eb2c6a…), from which the decoder
can't complete a message. It misreads the frames after it (its clock jumps ten hours;
in the demo's build that frame takes 5 s), then finds no message boundary again, so it
answered only the first eight of the 60 pings and applied 14,160 quote changes. The host
now stops there and names the frame. Without those seven frames, every message is taken,
all 60 pings are answered with themselves, as the browser's were, and 84,160 quote changes
apply to 3,279 issues (values relative to the missing snapshot: Toyota's bid and ask read
0). The browser's decoder, the same build, answered all 60 pings, so it was probably not
given those frames as recorded: its client may drop them, they may come from another
connection, or the capture may have damaged them. A live session will tell. The offline
check (tools/brisk_mock/sbi_master_layout.py) replays the stream both ways.
(pybrisk's own capture_summary.json has wrong bytes in 2,478 of these messages; the
archive's copy matches pybrisk's per-message files.)
What is not verified against a live session: any of the above on a broker's own stream, the
18000 master layout on today's build (found on SBI's of March 2026; each start checks it
against the master file), the stream's opening messages and whether it is plain binary
as recorded. Live, the master and the snapshot load with the schedule; the WebSocket
upgrade to api.brisk.jp was then answered with HTTP 502 (400 without the session
parameter, 404 on the site host, and no HTTP/2 extended CONNECT) with every header
variant tried, all without the flex subprotocol, which the host now asks for; whether
that is all the server wants is not known yet. The Socket.IO fallback's parameters live in a
profile (BRISK_PROFILE, or briskapi.connect(site=..., profile=...)) and are unverified. The
profile can also name another catch-up format (catchUp).
--trace-protocol prints the connection with every token redacted, so a first
attempt shows what the server answers. Anything wrong ends in an explicit error (a
stream that never initializes, a refused namespace, a rejected catch-up, an
implausible stock view or master) rather than guessed data. A connection lost once a day
has begun reconnects (Trading days); other failures end the recording.
Quote workers¶
Reading quotes is most of a live host's work. On the demo the WASM decodes the whole
recording in about a second, while reading its 731,251 quotes takes about 20 s. At the
opening auction the whole market changes within seconds (a live open has sent some 130,000
quote updates a second), so for a selection of 500 issues or more (a recording is the whole
market) the host reads quotes with worker threads (workers.cjs). By default there are as
many as the computer has cores less four, from two to eight; BRISK_WORKERS sets the number,
and 0 turns them off. When the feed serves a gateway, a quarter of them (at least one) read the
fast lane's quotes instead (Live gateway).
Each worker runs a decoder of its own, loaded from the same files and boot response. It is
fed every frame the host's decoder is, in the same order, with the catch-up and begin()
where the host has them, and it reads the bootstrap's quotes of its share as the host's
decoder did. It reads the quotes of its share of each frame's changed issues
(issue_id % workers), as JSON, and clears its own trace and trade records. The host's
decoder does everything else: the stream's callbacks (ping replies, heartbeats), its clock,
the changed issues and the trades. With workers the host decodes each frame as it arrives
and queues its batch, and writes the batches in order as their quotes come back. It builds
each line from the workers' JSON, so the batches are byte for byte what one thread writes
(tested on the demo's whole replay, catch-up included). A worker must have traced exactly
its share of the host's changed issues, or the session stops: its decoder would have
diverged. Quotes waiting to be written may not pass 1 GiB; the session stops rather than
fill the memory when the reader falls that far behind. A worker's decoder holds about 270 MB.
Parsing the host's lines is most of the Python feed's work (about 9 µs a quote update on one
thread). Without the GIL (free-threaded Python 3.14) one thread reads the pipe a chunk at a
time (up to 1 MB), PARSERS threads (up to four) parse the chunks, and the feed's thread
applies the batches in order; the spool builds its Parquet parts on threads of its own (above). With
the GIL, the lines are read and parsed one at a time, and the spool's thread takes turns with
the reader. Threads that share a set take its lock on every use, so code run on several threads
(Columns.from_batches) compares with copies of its own: with the shared key sets eight threads
built columns 1.3 times as fast as one, with copies 2.7 times. On an M5 Pro, the demo's whole replay, served as fast as it is taken, goes through
it all (the host and its workers, the feed recording to disk and serving the gateway) at about
61,000 quote updates a second without workers and 236,000 with 4 (more do not add); a live
open sends some 130,000. Slower cores take fewer: there the opening can fall a few seconds
behind, which the status line of brisk live shows in its data time.
Trading days¶
A recording covers one trading day, and record() in brisk.cjs runs one live()
session after another (brisk live until it is stopped). A day ends, with an end
batch, when:
- the site's boot serves a new date, or says the session is
done(session_status; the vendor app stops reconnecting then): the host asks/api/app/booteveryDAY_CHECK_MS(ten minutes). A failed check is only traced, since the stream's own checks catch a dead session; - the stream's clock goes back by more than an hour (
NEW_DAY_US), as a new day's would. A smaller step back is not a new day, and a recording with one fails the archive's market-clock check; - the server closes the stream, cleanly (code 1000) or, once the decoder has called
marketFinished, in any way; - the host is asked to stop:
main()handles SIGINT and SIGTERM by ending the running day (stop.end, the day'sendDay) andrecord()then returns instead of waiting for the next day; with no day running the host simply exits, and a repeated signal changes nothing.
Any other close, and any failure of the stream once it has started (stale data, missing
heartbeats, a dropped WebSocket), ends the segment the same way under record(): live()
emits the segment's end and throws Interrupted, and record() connects again after
RETRY_MS (5 s, 10 s, 30 s, 1 min, then every 2 min; the waits grow while connecting keeps
failing). The new session boots afresh and starts from a new snapshot, numbered from 0, so the
segment before the drop is a recording of its own and the Python feed packages and uploads it
like a day; only the gap is missing, and the cloud API merges the day's recordings. What another
connection cannot mend stops it with the error (fatal: an expired session, another WebSocket
session for the user, a host or site layout that changed, unknown codes), and so does any
failure before the first segment started.
The next day begins by asking the boot until the site serves a later date than the one
just recorded, with its session running, then boots afresh: the frontend and app boots, the
decoder, master and snapshot, and the catch-up. A start when no session is running waits the
same way. The vendor app opens the stream only while session_status is running; after the
close it is done and the boot has no ws_url (the host used to resolve the missing URL to
/undefined and fail). As the app reloads a minute after next_date (both it and the boot's
time in unix seconds, so the wait is on the server's clock), the boot is asked again then, or
in ten minutes if that is sooner; once it is due, every minute (STREAM_CHECK_MS). The
host says so once per wait, on stderr and to Python as an out-of-band line
{"type": "waiting", "date", "session_status"} (no seq): the feed keeps it nowhere (not in
the spool, a recording or the gateway), shows it as feed.waiting, and until the first
bootstrap has the status waiting, during which ready() and connect() wait past their
timeout. A stop while waiting (Ctrl-C, feed.close()) just exits: there is no day to end. Its batches are numbered from 0,
so each day is its own bootstrap ... end, and the Python feed keeps it as its own
recording. The wait is plain HTTP, so a session that expires meanwhile, or any other
failed request, ends the recording with that error instead of waiting on; how long a
broker's session cookie lasts is not known. The wait, the roll and reconnecting are tested
offline against fake servers (a server that closes after the first day, a site that serves
the next date at a later check, boots with no session running before a day and after one, a
connection that drops mid-session, boots that fail and a session that expires before
reconnecting). The clock rule has no test, and none of it has run
against a live session.
Tachibana e-shiten BRiSK Next¶
e-shiten's BRiSK Next (next.e-shiten.brisk.jp) is the same app. In sessions on
2026-10-08, the two sites' /api/app/boot responses matched (flex_version
18000, the same master and snapshot hashes) and they served the same decoder
(fita.35e38ad5…), reached only through the template path above. On both, cookie
sign-in, every REST endpoint and the decoder, master and snapshot downloads worked.
Both stopped while reading the master with the demo's 144-byte records (a lot size
beyond 2^53), so the WebSocket was never reached: the decoder's 18000 master records are
not the demo's (above: 148 bytes; read at 144, the records drift until a lot size is
garbage). Their master file was about 800 KB for 4,443 issues, which fits the same
180-byte file records.
e-shiten enforces one BRiSK session per user: opening
it in a second tab stopped the first (message 10011).
site="e-shiten" selects it (BRISK_SITE=e-shiten for the host; saved cookies in
e-shiten-cookies.json, BRISK_ESHITEN_COOKIES). Its batches say
source=eshiten_live and kind=eshiten_websocket. Its sessions are uploaded as
SBI's are: a timing summary and, after each day's clean end, the market recording, under the saved
choice and with the same opt-outs; packaging keeps only next.e-shiten.brisk.jp as its
transport provenance (BROKER_TRANSPORT in schema.py). brisk login can sign in to it with a passkey (site e-shiten; see
Tachibana e-shiten's sign-in).
Matsui, Monex and SMBC Nikko¶
Matsui (matsui.brisk.jp), Monex (monex.brisk.jp) and SMBC Nikko (smbcnikko.brisk.jp) run
the same vendor's BRiSK on their own hosts. Checked on 2026-10-14 without an account: each answers
/ with 401 to a request without a session, as sbi.brisk.jp and next.e-shiten.brisk.jp do,
and, like e-shiten's, none serves SBI's current app bundle by name, so each serves its own build.
The host never assumes a build: as on e-shiten, it reads the signed-in page for its bundles, finds
the decoder through them and checks its exports (a build it does not recognise is an explicit
error, not doubtful records), and the board reader works out the build's ladder layout itself
(BoardReader above), falling back to _getItaRows when it cannot. None of the three has been
tried with an account.
site="matsui", "monex" or "smbcnikko" selects one (BRISK_SITE for the host; saved cookies in
<site>-cookies.json, BRISK_MATSUI_COOKIES, BRISK_MONEX_COOKIES, BRISK_SMBCNIKKO_COOKIES). Their
batches say source=<site>_live and kind=<site>_websocket, uploaded as SBI's are, with the site's
own origin as transport provenance. Their passkey sites (briskapi/sites.py) are bound to these
clients, so brisk login hands the cookies to briskapi.login(). Their cookie names are not
known, so cookie_prefix is empty and any cookie on the BRiSK host counts as signed in.
Passkey sign-in¶
briskapi.passkey (CLI: brisk sites | enroll | login | forget) gets a broker's BRiSK session cookies by
signing in with a passkey, in place of copying them from a browser. It is not tied to any one broker:
a site (briskapi/sites.py) says where the login page is, what its passkey button says and which host
the BRiSK cookies belong to. SBI, Matsui, Monex and SMBC Nikko are built in (each runs BRiSK on its own
<broker>.brisk.jp, found by checking the hosts and the brokers' own pages; none is verified against a
live account), and so is Tachibana e-shiten (BRiSK Next on next.e-shiten.brisk.jp, its sign-in mapped
from its public login page). ~/.config/brisk/sites.json adds or edits entries. A person normally has one
brokerage, so the saved login is a single record, {site, credential}, and login needs no site argument.
A site with a user_field (e-shiten) also keeps the user_id that enroll asked for.
How a passkey sign-in works, and why Chrome can stand in¶
A passkey is a WebAuthn credential: a key pair made for one site (its relying party id, the site's
domain). Registration makes the authenticator create the pair and hand the public key to the site.
Signing in goes like this: the site sends a random challenge; the browser passes it, with the page's
origin, to an authenticator; the authenticator checks that the origin matches the credential's relying
party id, confirms the user (presence, and verification such as a fingerprint) and signs the challenge,
the origin and a counter; the site checks the signature against the stored public key, the flags and
that the counter did not go backwards. Nothing in that exchange says what kind of authenticator signed.
A site can only learn that from an attestation statement made at registration, and browsers and
sites mostly use "none" for passkeys. So an authenticator that is software, and that reports presence
and verification as done, produces a sign-in the site cannot tell from a phone's. Chrome's DevTools
WebAuthn domain provides exactly that (a virtual authenticator), including exporting and importing
a credential, which is how the passkey is kept between runs. Two things limit it: the site may detect
the automated browser by other means, and the passkey is now a secret that sits in software.
Passless-style virtual USB devices were ruled out: they need /dev/uhid (Linux only), and on macOS
creating a virtual HID device needs an entitlement that Apple grants on request. The Chrome route
needs neither and runs wherever Chrome and Node do: macOS, Windows and Linux. On Windows it is run from
Windows itself, using Windows' Chrome, Node and Python; WSL is Linux and would need its own Chrome.
The helper¶
decoder/passkey.cjs runs under Node and drives a Chrome it starts itself. It holds no site of its own:
the login URL, button text, cookie host and any user ID box and user ID come in the request.
- Private pipe, no port. Chrome is started with
--remote-debugging-pipeand talked to over file descriptors 3 and 4 (NUL-framed JSON). A loopback debugging port would let any local process attach and read the passkey back out withWebAuthn.getCredentials. Windows passes those two descriptors to Chrome too (Chrome has a separate named-pipe switch for Windows, which is not needed): checked with native Node 25.8 and Chrome 154 on Windows 11. - Ephemeral profile. The profile is a new private directory, deleted when Chrome exits
(
--profile-diropts into a persistent one). It holds the broker's logged-in session, so a helper that is force-killed (which cannot clean up after itself) must not leave it around: each profile records its owner's process id, and every start removesbrisk-passkey-*profiles whose owner is gone, leaving live and just-created ones alone. The mock keychain and basic password store keep Chrome from prompting for Keychain access. Chrome reportsnavigator.webdriveras true whenever DevTools is attached (checked in both modes), and headless Chrome's user agent says so too, so a site that blocks automated browsers can tell. The host does not hide this. The default is a visible window, which lets a person take over where a guess about a broker's pages is wrong or the broker refuses the automation. - A virtual authenticator on every page, including popups and new tabs
(
Target.setAutoAttach, with new pages held until it is in place). It is a CTAP2.1 platform authenticator with resident keys that approves presence and verification itself. A page gets the latest copy of the passkey, so a popup that opens after a sign-in in the same run signs with the advanced counter rather than the one Chrome started with, which the site would refuse as a clone. - Enrolling attaches an empty authenticator, opens the site's
enroll_url(or its login page) and waits forWebAuthn.credentialAddedwhile the person registers a passkey. The captured credential is kept in memory and saved, with the chosen site, only after the person confirms, so a failed enrollment never replaces a passkey that still works.enrollrefuses to overwrite without--replace, and with no--siteit asks which broker (or fails, without a terminal). - Signing in imports the saved credential, opens the login page, types the saved user ID into
the site's
user_fieldwhen it has one (Input.insertText, falling back to the value setter andinput/changeevents; without the box nothing is pressed), clicks the control whose text contains the site'spasskey_button(trustedInputmouse events; the shortest matching label wins, so a "can't sign in?" link does not; a control drawn as an image is labelled by itsalt) and waits forcredentialAssertedfrom any page, so a ceremony in a popup counts. Then it openslaunch_urlwhen there is one, or waits for the person to open BRiSK, and polls for a cookie that starts with the site'scookie_prefixon exactly its BRiSK host. Only that host's cookies are returned, never those of the broker's main site, whose session is destroyed with the profile. - Delivery. The five built-in brokers have a data client (
briskapi.brisk, with the site's ownsite=), so their cookies go tobriskapi.login()and are saved as that site's cookies; those of a site added insites.jsonare saved to~/.config/brisk/cookies/<site>.json(owner-only) for the person's own use. A data client sends its cookies to one host, so a site bound to a client whose cookie host is not that client's host is refused before Chrome starts (asites.jsonentry can never claim a client).
The sign counter is the delicate part. A relying party may reject an assertion whose counter does not exceed the last it saw, as a sign of a cloned authenticator, and every sign-in advances it. The host therefore streams the credential as JSON lines after every change, the Python side saves each one at once, and a failed or interrupted run still reports the latest. If a save fails, the helper is stopped rather than allowed to sign in again with a counter that would no longer match.
Secrets move only over stdin and stdout, never on a command line or in a log message (logs
name origins, never paths or queries, and the helper refuses to run attached to a terminal).
The saved login is kept in the macOS Keychain through security -i, which reads its command from
stdin: add-generic-password -w SECRET would show the secret in the process list. security
exits 0 even when a write fails, so each write is read back and compared. On Windows it is one
Credential Manager entry for this user on this machine (CredWriteW and friends through ctypes, no
extra dependency; an entry holds at most 2,560 bytes, which a passkey fits in), also read back after
each write. Elsewhere it is stored only if BRISK_PASSKEY_STORE=file asks for an owner-only
file, which is weaker (and on Windows a file's privacy comes from its folder's ACL, since 0600 means
nothing there). The private key is the whole credential: whoever has it can sign in.
Three things differ on Windows, and the code and tests cover each. A process cannot be signalled to stop
there (terminating one kills it outright, which would leave Chrome and its profile behind), so the
helper treats a closed stdin as the request to close Chrome and exit, on every platform; the Python side
closes it first and terminates only if the helper does not exit within GRACE (20) seconds, which is
longer than closing Chrome and deleting its profile can take. (Killing the helper outright does make
Chrome exit on its own within about a second on Windows, because its pipe closes.) Text between
Python and Node is always UTF-8, because Python's default there is the system code page (cp1252) and a
Japanese user name or button text would fail to decode; sites.json is read as UTF-8 and also accepts
the byte-order mark and the UTF-16 that Windows editors and PowerShell 5.1 write. And deleting the temporary profile retries,
because Windows can keep a file locked for a moment after Chrome exits.
Tests run Chrome against a fake broker (tools/brisk_mock/fake_sbi_passkey.cjs) that verifies
what a real site verifies: the ES256 signature, the relying-party hash, user presence and
verification, and a counter that must increase. They cover enroll, sign-in, a stale passkey
being refused as a clone, launch_url, a missing passkey control, delivery to a data client and to a
file, and that nothing secret is logged. A page shaped like e-shiten's (user ID box, image-only
control, ceremony in a named popup that reports to its opener) covers typing the user ID, a missing
box, and a second ceremony in a popup after a first one. They skip when Chrome is absent. They were run on macOS, on native Windows 11 (Windows' own Node,
Python and Chrome; the full suite including the real Credential Manager) and, in CI, on Linux.
What is not verified against any real broker: each site's login URL (SBI's redirected to a
maintenance page when checked, SMBC Nikko's and Monex's refuse a bare request), the passkey control's
text, how BRiSK is launched from the main site, the name of the BRiSK session cookie (only SBI's is
known, so the other sites accept any cookie on their BRiSK host once it appears), whether a broker
accepts a virtual authenticator at registration or asks for more identity checks, whether it refuses
an automated Chrome (see above), and how long the cookie lasts. Each is an overridable default or a
manual step, and a wrong guess ends in an explicit error. e-shiten's login page is the one whose markup was
read (below); the rest of that list applies to it too. The automated runs on Windows are headless; the
visible Chrome window of enroll and login was also tried by hand on Windows 11 (the real console
prompt, Credential Manager) and on macOS. The DevTools WebAuthn domain is marked experimental.
The mechanism itself is verified against a real site that is not a broker, webauthn.io (a public passkey
demo; it logs you in on load when a passkey is present, which login catches because it listens before
loading the page): registration through the virtual authenticator, brisk login with its cookie and the
advancing sign counter, on macOS and by hand on Windows. A sites.json entry for it needs only
login_url https://webauthn.io/, cookie_host webauthn.io, passkey_button Authenticate and
cookie_prefix sessionid.
Tachibana e-shiten's sign-in¶
Mapped on 2026-10-11 from e-shiten's public pages, without an account. Its PC login
(www.e-shiten.jp/passkeylogin.html) offers 「パスキーでログイン」, a link to
tr2.e-shiten.jp/e-shiten?auth=passkey, and 「電話番号認証でログイン」, a link to the same address without
the query. The passkey form (Shift_JIS, jQuery) asks for the user ID (kokyakuUserId, letters and
digits) before the passkey. Its control, 「パスキーを使う」, is an <a href="#"> around an <img alt>,
with no text of its own. Its handler checks the ID, opens window.open('about:blank', 'passkey')
and submits the form into it. The ceremony runs in that popup, which postMessages the result
(cookies, SID) to the opener, and the opener submits it to finish signing in. So the
site entry is login_url the passkey form, user_field input[name="kokyakuUserId"] and
passkey_button 「パスキーを使う」. Enter in the ID box also presses it.
Registering needs a sign-in first: user ID, PIN and phone-number authentication at
tr2.e-shiten.jp/e-shiten (the enroll_url). Tachibana says the registration screen follows that
sign-in, and that passkeys are managed under お客様情報 › 設定情報 › パスキー認証. It also says
passkeys are required for trading and withdrawals from 2026-12-12, and that a withdrawal asks for
the passkey again (in the same kind of popup).
Not verified: whether e-shiten accepts a virtual authenticator at registration (it says Microsoft
Password Manager cannot register one, which may mean it filters authenticators); how BRiSK Next is
opened from the signed-in site (there is no launch_url, so it is opened by hand in the window); and
the BRiSK Next session cookie's name. cookie_prefix is session_ because the two sites serve the
same build, and SBI's cookie is session_…. If it is wrong, login ends with "No
next.e-shiten.brisk.jp session cookie appeared", and cookie_prefix: "" in sites.json accepts any
cookie of that host. Because e-shiten allows one BRiSK session, the one opened during sign-in is the one
the feed then uses: Chrome is closed by then.
Reconstructing a recording¶
To validate and reconstruct a saved or downloaded recording with the Rust state
(brisk_recording from a release archive, or built from source), export its batches
first:
brisk export recordings/downloaded --jsonl /tmp/events.jsonl
rust/brisk_quote_ingest/target/release/brisk_recording \
--input /tmp/events.jsonl --latest /tmp/restored-state.json
This rebuilds final state for offline analysis. It does not retimestamp data or simulate original exchange delivery.
Private archive¶
The configuration is briskapi/archive.json: bucket
brisk-recordings-honvl-tokyo in Tokyo (ap-northeast-1), the archive's function URL
(api_url) and, once the cloud API is deployed, its function URL (query_url; the client
raises BriskError for a query without one). Nothing in the bucket is public but samples/
(the legacy wire-format sample capture): each owner reaches its own uploads through the
functions only.
Deployment is described in infra/README.md.
Identity¶
There is no registration. On first use the client makes a random 128-bit secret,
brisk-id-v1- followed by 26 lowercase base32 characters (briskapi/identity.py), or takes
BRISK_IDENTITY. It keeps two copies: one in the system's credential store (service
briskapi-archive-identity, through passkey.py's Keychain and Credential Manager code)
and one in an owner-only file, identity.json in the config directory. Reading repairs a
missing copy from the other, refuses copies that differ, and never overwrites an unreadable
file (it is set aside as identity.json.unreadable). Every
API request but a ticket's status sends it as Authorization: Bearer <secret>, over TLS only:
the client refuses any URL but https (or plain HTTP to localhost, for a local service) and
never forwards the header on a redirect (cli.request_json, cli.secure). The
service derives the owner ID, the first 32 hex digits of
SHA-256("brisk-owner-v1:" + secret) (schema.owner), and keeps nothing else: no account,
no stored key, nothing to look up. Everything an owner uploads is filed under
archive/OWNER/ or timing/OWNER/, and listings, downloads, deletions and restores are
scoped to the caller's owner ID. Owner IDs can't be reversed to secrets, so they may appear
in prefixes, quotas and logs. Outside the owners' prefixes are the archive's single copy of
a demo replay's market tables (below), which holds no owner's data, and the cloud API's
bars/OWNER/ (bars built from the owner's own recordings) and results/OWNER/ (see
Cloud API). The cloud API takes the same secret as its key.
A secret is a bearer credential: whoever holds it acts as that owner. Its recovery code
is the same 26 characters in upper case plus two checksum characters (base32 of SHA-256 of
the body), in groups of four; reading it ignores case, spaces and dashes, takes 0, 1 and 8
for O, I and B, and refuses a code whose checksum fails. brisk identity --recovery-code
prints it, --recover CODE saves it on another computer (--replace over a different
identity) and --export prints the secret itself. It is never registered, so nothing can
recover a lost secret without its code. A signature scheme would keep the secret on the client entirely,
but needs Ed25519 or similar on both ends, which Python's standard library lacks; the
bearer secret needs no dependency.
The API (archive_service.py), JSON with an action:
| Action | Fields | Returns |
|---|---|---|
upload |
manifest, part |
A ticket and an S3 POST policy bound to incoming/TICKET/recording.zip, the exact size of what is sent and encryption. part is null for the whole recording (the zip of its four tables), or {sha256, bytes} of the zip of its timing table alone, which only a demo or synthetic replay may send. A timing-only upload is accepted once the archive holds the replay's market tables; until then the answer is HTTP 409 (send: recording), with no ticket issued or counted, and the client sends the whole recording. The owner ID waits in incoming/TICKET/owner.json for ingest, and a part in part.json. |
timing |
report |
The stored key. |
list |
kind (recordings, timing or deleted), date, after |
A page of the owner's manifests (50) or reports (500), and the key to continue after. deleted lists what the owner deleted and can still restore: recordings (prefix, manifest) and reports (key, report), each with deleted_at and restore_until, up to 200 and without paging (after must be null). |
download |
prefix |
The manifest and a 15-minute presigned URL for each of the four tables (files), for the owner's own prefix only. A demo recording's market tables are the archive's copy under reference/. |
delete |
prefix (a recording's) or a timing key |
The current objects deleted, the manifest first, so a listing never shows half a recording. Without a version ID the versioned bucket keeps them as noncurrent versions, which restore can bring back for 30 days and the lifecycle removes after 31; the execution role can't delete archive or timing versions. A replay's reference/ tables are not touched. The owner's bars of a live recording's trading day are then rebuilt without it. |
restore |
prefix (a recording's) or a timing key |
{"status": "restored", "restored": N}: each key deleted in the last RESTORE_DAYS (30) days is copied back from its newest kept version (copy_object), the manifest last. Not found when nothing is restorable. The owner's bars of a restored live recording's trading day are rebuilt with it. See Deleting, restoring and retention. |
GET /?ticket=UUID returns a ticket's status; the unguessable ticket is its credential.
Every request, a ticket's status included, first counts against its source address, 120 a
minute (REQUESTS), before its identity is read. Identities are free to make and any
well-formed one is accepted, so an address, not an identity, is what bounds guessing: with
2^128 secrets, finding one that has uploads is hopeless at any rate, and the limit bounds what
trying costs. Addresses count as keyed hashes (address), an IPv6 address as its /64 (one
connection's usual block, so that moving within it does not reset a limit). Deletes and
restores, each of which may rebuild a day of the owner's bars, are limited to 60 an hour per
address and per owner (CHANGES). A request over any limit gets HTTP 429.
Integrity¶
The client is open source, so the service validates every upload. Demo and
synthetic content must match a reference, and is checked whole, in memory
(verify_directory); broker live content has no reference and is uploader-declared, and
is checked a row group at a time on disk (verify_broker), as a trading session of the whole
market is some 25 million quote updates. Every upload is checked for what it may hold (the
tables, fields and encodings below); a broker's for being well formed too (each quote's and
trade's identity, order, batch, clock and value ranges, its books' order, the batches'
sequence, kinds, clocks, transport and local timing, and the end's count), but not for the
market's own consistency (trades against volumes, each issue's frames over time), which
nothing could make trustworthy:
- Reference replay. Market content must equal the pinned demo replay.
briskapi/references/historical_mock.json holds a
truncated SHA-256 chain per security (master entry plus every update and the
batch it arrived in) and a hash of the batch clock timeline; it contains no
market data. Any subset of securities is accepted, but only complete replays.
tools/brisk_mock/build_reference.pyregenerates it after a re-audited asset pin. This comparison applies only to demo/synthetic sources, not to the broker ones (sbi_live, ...). - Canonical tables. Each table must have exactly its schema and metadata, a
pyarrow writer, zstd, full row groups and no bytes between its column chunks.
Every row group must equal the encoding of the validated content, including the
columns reading ignores (
ts_init,flags, v2's constant Equity fields), null for every zero price and rows sorted by issue, then batch. Packaging converts recorder output to this form and rounds local timings to microseconds. - Exact fields. Batches, master entries and quotes must have exactly the
fields defined in
briskapi/schema.py. Account, session or unknown fields are rejected. Local timing values must be plausible: decode ≤ 10 s, receipt clock monotonic within 24 h, millisecond values with at most microsecond precision, and pacing consistent throughout. End-of-stream counts must match. Broker quotes use null replay lateness. Its end frame count includes pre-bootstrap catch-up frames, so it can exceed the number of emitted quote batches. Broker packaging removes connection/decoder diagnostics and keeps only the site's fixed transport metadata,{"kind":"sbi_websocket","origin":"https://sbi.brisk.jp/"}for SBI (eshiten_websocketandhttps://next.e-shiten.brisk.jp/for e-shiten, and so on:BROKER_TRANSPORT). The service rejects any additional transport fields. - Verified tables, no uploaded zip. The service stores the uploaded tables
once verified, each as its own object. The zip that carried them is never kept, so
its comments, extra fields and timestamps never reach the archive, and the
manifest it stores hashes the service's own deterministic zip of the four tables as
stored. The tables themselves are not rewritten: what
table verification can't see (Parquet statistics, unused dictionary entries,
encoder choices in pages) is the one place an upload could keep a few bytes of
its own. For a demo replay's market tables, the first verified upload's bytes become the
archive's copy.
synthetic_testaccepts only the fixed one-security probe inbriskapi/schema.py. - A timing table alone is verified as a whole recording. When an upload sends only its timing table, the service fetches the archive's copy of the market tables, assembles the four, and runs the same checks as for a whole recording (reference replay, canonical tables, exact fields, and the summary in the uploaded manifest) before storing the timing table. The timing table is therefore checked against the market data it describes: batch sequence, clocks and counts.
- Nothing lingers in staging. Each upload is deleted right after validation, whether accepted or rejected. A ticket admits only its first object version; later POSTs with the same upload form are deleted on arrival.
- Malformed input. Corrupt zips, unexpected zip members, compressed members and unreadable Parquet are rejected like any other invalid upload. Row counts, list lengths and declared decoded sizes are checked before data is read.
A client chooses its bounded timing measurements, which cannot be verified. Broker master names and market values also remain unverified uploader content; exact fields and canonical encoding do not establish market accuracy. Upload quotas apply to every source.
Storage and limits¶
A recording is stored as its four tables (_parquet.FILES), not as the zip it arrived
in. Only the staging object incoming/TICKET/recording.zip is a zip, and it is deleted
once validated:
archive/OWNER/YYYYMMDD/FROM-TO-SHA256/
data/equity.parquet } a demo or synthetic replay keeps these
data/order_book_depths.parquet } three in reference/ instead (below)
data/trades.parquet }
data/custom/BriskBatch.parquet the timing table: always the recording's own
files.json where each of the four tables is
manifest.json written last: the commit marker
reference/SOURCE/TIMELINE/
data/equity.parquet data/order_book_depths.parquet data/trades.parquet
complete.json written after the tables
timing/OWNER/YYYYMMDD/SHA256.json
bars/OWNER/YYYYMMDD/
ohlcv-1s.parquet ohlcv-1m.parquet the owner's bars of the day, from its live recordings
ohlcv-1h.parquet ohlcv-1d.parquet (derived; see Cloud API)
results/OWNER/UUID.EXT a query result too large to return inline
- Recordings are content addressed per owner. FROM and TO are the recording's first and
last source clock (JST, HHMMSS:
schema.recording_name, matched byRECORDING_PREFIX), so a day's recordings list in time order, and SHA-256 is that of the service's zip of the four tables as stored, the one the stored manifest hashes. Storage writes the tables first, thenfiles.json, and the immutable manifest last; listings and downloads read only committed recordings. - A demo or synthetic replay's market tables (equity, depths, trades) are the same for
every recording of it, so the archive keeps them once, under
reference/SOURCE/TIMELINE/, where TIMELINE is the reference's batch-clock hash (reference_prefix). The first verified replay becomes the copy: its tables are written with conditional puts andcomplete.jsonafter them, so a race keeps the first of each table and a copy withoutcomplete.jsonis not used. A recording of that replay, the first included, stores only its timing table, and itsfiles.jsonpoints the other three into the copy.downloadaccepts afiles.jsononly if it names exactly the four tables, each under the recording's own prefix or its source'sreference/prefix. The role can read and writereference/but never delete there, and no owner can list it. - An upload to a replay with a stored copy sends only the timing table:
uploadwithpart, the size and SHA-256 of the one-table zip, and a ticket bound to that size. Without a copy the service answers 409 and the client (cli.upload) sends the whole recording;market=Truesends it regardless. Ingest verifies the assembled recording in full either way (see Integrity). - Upload tickets last 15 minutes and bind the object key, exact size and encryption.
- Uploads are bound to their first S3 object version. Retries of that event are safe, and overwriting an upload cannot replace a stored recording.
- Limits: a demo or synthetic replay, a 64 MiB package, 2 GiB in canonical JSON form and
16 MiB per batch in that form; a broker's recording, a 4 GiB package (S3 takes a browser
POST of up to 5 GB) and
BROKER_MAX_ROWSrows, uploaded and stored from disk, never in memory (the function has 10 GB of/tmpand 15 minutes). Tickets are limited to four per IP address per hour (stored as a keyed hash, never the address), eight per owner per hour, 64 per hour in total, 8 GiB of authorized uploads per owner and 64 GiB in all per UTC day, counted by what is sent (a timing table alone, once the copy exists), on top of every request's per-address limit (see Identity). A complete demo replay is a 41 MB package. - Ticket metadata in staging expires after two days. Stored data is kept until its owner deletes it; what an owner deletes can be restored for 30 days and is removed after 31.
brisk prune DIRremoves a local package only after finding its SHA-256 in the owner's listing and re-hashing the local tables. The listing's hash is the service's, over the tables as stored, so a demo recording is found only if its market tables are byte-identical to the archive's copy.pullasksdownloadfor the manifest and one presigned URL per table, and checks the manifest and archive identity before fetching any. It downloads the four tables (stopping beyond the manifest's byte count), packs them into the deterministic zip and checks that zip's size and SHA-256 against the manifest, then the tables, summary and reference replay (inspect_package). It moves the folder (the tables and the manifest; no zip is kept) into place atomically.- Listings warn and skip invalid/unsupported manifests, including old probes with incomplete reference metadata, without hiding S3/network failures.
Deleting, restoring and retention¶
delete removes the current versions of an owner's objects, the manifest first. The
bucket is versioned, so each object stays as a noncurrent version beneath a delete marker.
The execution role may delete such a version only in staging (s3:DeleteObjectVersion),
so nothing the function does, bugs included, purges one under archive/ or timing/:
only the lifecycle does.
listwith kinddeletedandrestoreread the bucket's versions under the owner's prefixes (s3:ListBucketVersions,versions()in the service). A key counts as deleted when its latest version is a delete marker, and is restorable when an older version is kept and the marker is at mostRESTORE_DAYS(30) days old. The listing reads each manifest or report from its kept version and addsdeleted_atandrestore_until.restorecopies each such key's newest kept version back to its own key (copy_objectwith that version ID, so a new current version), the manifest last, so a half-restored recording is never listed. After 30 days it finds nothing.- The bucket's lifecycle (
deploy.py: rulesExpireDeleted-archiveandExpireDeleted-timing) expires noncurrent versions underarchive/andtiming/DELETED_DAYS(31) days after they became noncurrent, a day after the restore window, and removes delete markers once no version is left beneath them. S3 applies lifecycle rules asynchronously, so removal can trail that by a day. Current objects are not affected, andreference/has no rule, since no owner deletes there. - A live recording's
deleteandrestorealso rebuild its owner's bars of that trading day (owner_barsin the service). The role deletesbars/objects the same way it deletes archive ones (current versions only), and the lifecycle ruleExpireReplacedBarsremoves the noncurrent versions of replaced or removed bars after a day.
Sources¶
The decoded schema accepts historical_mock, synthetic_test and the brokers' sbi_live,
eshiten_live, matsui_live, monex_live and smbcnikko_live. The small, self-authored
synthetic_test fixture verifies uploads; filter by source when selecting market data. Uploaded
decoded broker recordings retain their source and undergo the canonical-table and structural checks above.
Their market accuracy is uploader-declared; they are not silently treated as
reference-verified demo data. New raw-wire uploads remain unsupported. Objects stored
while the archive was public (before policy version 6) sit outside any owner's prefix and
are unreachable through the API.
Cloud API¶
Databento-style queries over an owner's recordings: the records of a dataset, symbols, schema and time range across days, without downloading recordings. The HTTP interface, what is decided and the plan for later schemas are in docs/cloud-api.md; usage is in the README.
query_engine.pyreads the archive bucket (throughBucket, a boto3-style client) and turns recordings into records (withbriskapi/records.py, whichbriskapi.Liveshares) and bars.query_service.pyis the Lambda function URLbrisk-recorder-query: authentication, endpoints, quotas and delivery of results.briskapi/_historical.pyis the client (Historical,DataStoreandbrisk query).
The engine and the service are server side, like archive_service.py, which imports the
engine to keep owners' bars in step: the wheel ships only briskapi/, and package() in
deploy.py puts them, with briskapi/schema.py, _parquet.py and records.py, in the one
zip both functions run. Historical's OHLCV bars therefore come only from the cloud;
briskapi.Live builds its own from the stream (see Live gateway).
Datasets and requests¶
XTKS.BRISK (publisher_id 1) is an owner's live recordings, from any of the five brokers (LIVE in query_engine.py):
any broker's BRiSK is the same app and stream. XTKS.BRISKDEMO (publisher_id 2) is the
public demo, read from the archive's one copy of its market tables under
reference/historical_mock/TIMELINE/ (the same for every identity; HTTP 404 until a verified
whole demo upload, or deploy.py --seed-demo, has created the copy). request() validates
before anything is read: the dataset; the schema (trades, mbp-10, definition,
imbalance, brisk-quotes, ohlcv-1s, ohlcv-1m, ohlcv-1h, ohlcv-1d); stype_in raw
symbols only; 1 to 2,000 symbols (JPX codes of four or five characters, 0-9 and A-Z) or
ALL_SYMBOLS; at most 31 JST trading dates; a positive limit. A date is that JST trading
day's midnight, a time without a zone is JST, an integer of ten or more digits is UTC
nanoseconds, and end is exclusive and defaults to the end of the start's trading day (so an
end date excludes that day). instrument_id comes from the code, because a recording's
issue_id only indexes that day's master: digits-only codes are their number, others are
100,000 plus the code's base-36 value (167A is 154,694).
Records¶
recordings() lists archive/OWNER/DATE/ for each trading date a request touches, reads each
manifest (its summary gives the source, the first and last source clock and the codes) and
files.json, and keeps live sources. Bucket.parquet hands pyarrow a ParquetFile over a
file object that fetches 4 MiB blocks by ranged GET as they are asked for, so only the blocks
holding the footer and the needed row groups are read. _rows skips a row group whose
issue_id range (tables are sorted by issue) or ts_event range, from its statistics, cannot
match, then filters the rest exactly. Each schema is a projection:
| Schema | From | Mapping |
|---|---|---|
trades |
trades | action T; side B for a BUY aggressor, A for SELL, else N; price (float64, yen), size, sequence (the frame) and trade_id |
mbp-10 |
depths | One row per quote update with both books flattened to bid_px_00, bid_sz_00, bid_ct_00 … ask_ct_09 (null past a side's last level). BRiSK gives each update's book, not the order event that changed it, so action and side are N, depth 0, price null and size 0; flags is Databento's snapshot flag (32) on the bootstrap batch's rows (seq 0), else 0 |
imbalance |
depths | Per update, an opening (O) row where the opening indicative price shows and a closing (C) row where the closing one does: ref_price, ind_match_price, paired_qty (indicative volume), side, market_buy_qty, market_sell_qty, market_imbalance_qty and _side (buy minus sell), bid_total_qty and ask_total_qty (the board totals; the closing ones for C), the day's limit prices as collars and auction_time (special quote time) |
definition |
equity | One row per security per recording, stamped JST midnight of its trading day and only when the request reaches that day: raw_symbol, exchange XTKS, currency JPY, lot size, tick increment, limits, base price, name, issue type, tick type |
brisk-quotes |
depths | Every column the update has except identifier, ts_init, issue_id and seq; decimals become float64 |
ohlcv-* |
trades | Below |
Every schema but bars starts with ts_recv, ts_event, publisher_id and instrument_id
and ends with symbol. ts_event is BRiSK's stamp. ts_recv is the uploader's receipt
time: received_unix_ms of the update's batch, joined by seq (milliseconds; ts_event
where there is none), and ts_event for the demo, which has no receipt clock of its own. A
request filters and sorts on ts_event, not on ts_recv as Databento does: overlapping
captures of one day agree on ts_event and differ in ts_recv, so the same range returns the
same rows from any of them.
A day recorded twice (a restart, two computers, two brokers) gives overlapping recordings.
Their rows are merged and deduplicated, keeping the first, by (instrument_id, trade_id)
for trades, (instrument_id, ts_event) for definitions, (instrument_id, sequence,
auction_type) for imbalance and (instrument_id, sequence) for the rest. The frame
numbers are the server's, which should make captures agree; that is not yet confirmed on live
data across brokers. Rows sort by ts_event, instrument_id and sequence, and limit cuts
after the sort. With no recording in range, a result has only the common columns and symbol.
encode() writes Parquet (zstd, timestamp[ns, UTC] times), CSV or JSON lines. For CSV and
JSON, pretty_ts gives ISO 8601 UTC times with nanoseconds instead of integer nanoseconds,
pretty_px yen as numbers instead of integers in units of 1e-9 yen (Databento's fixed point),
map_symbols keeps or drops symbol and compression=zstd compresses (.zst); nested
columns are JSON text in CSV.
Bars¶
build_bars aggregates trades (symbol, trade_id, frame, ts_event, price, size) into open,
high, low, close and volume per security and interval, for all four lengths. Several sources
are deduplicated by (symbol, trade_id), then ordered by symbol, ts_event, frame and the
trade's place in its frame, so open and close are the first and last trade. Intervals are
aligned to JST (a day bar runs from JST midnight), a bar's ts_event is its start and an
interval without a trade has no bar. XTKS.BRISKDEMO bars are built from the demo's trades on
the fly and returned whole.
For XTKS.BRISK an owner's bars are built ahead, from its own current live recordings of each
trading day, each trade counted once. bars/OWNER/YYYYMMDD/ holds the four files (zstd, sorted
by symbol in 65,536-row groups, so a query for a few securities reads only their row groups).
owner_bars in the archive service runs after store commits a live recording, after delete
and after restore: rebuild_bars reads the trades of the owner's live recordings of that day
and writes all four files, or removes them when none is left. Rebuilds of one owner's day are
not serialized, so a rebuild that finds the owner's keys for the day changed while it ran runs
again (up to three times). Bars are derived data, so a rebuild that fails removes the day's
files (the service prints Bars for DATE not rebuilt) rather than keep bars that no longer
match the recordings; queries then build them from the recordings, with the same result.
covered keeps only the bars the recordings cover completely: a bar whose span, clipped to
the Tokyo Stock Exchange's trading sessions (SESSIONS: 09:00 to 11:30:01 and 12:30 to
15:30:01 JST, half open; the extra second holds the closing auction's prints), lies inside the
merged clock ranges (first to last source clock) of the owner's recordings that hold that code.
A bar that touches no session is judged on its whole span. So a one-second bar needs its second
recorded, and an hour or day bar needs the whole of its in-session part. Securities held by the
same recordings share their windows, so it is one vectorized pass per such group (usually one),
not one per security.
No other owner's recordings enter an owner's bars, and nothing is lost by that: within its
coverage an owner's own recordings hold every trade, since validate_batches refuses a
recording whose trades do not account for every change in a security's cumulative volume and
turnover between its quotes. Others' recordings could only disagree there. 1.0.0 built one set
of shared bars per day from every owner's recordings and cut each owner's to its coverage, which
a made-up recording (it passes validation: nothing ties a broker recording to the real market)
defeated twice: two batches at 09:00 and 15:30 claimed a whole day's coverage and received
everyone's bars, and a made-up price for a real print, from an owner ID ground to sort first,
won deduplication and replaced the real one in everyone's. test_query.py keeps both
(test_no_upload_reveals_or_changes_another_owners_bars).
Speed, on a day of 1,000,000 trades across 3,774 securities (measured locally, without S3
latency): a rebuild takes about 0.8 s; a query reads one-minute bars in about 1 ms for one
security and 60 ms for every security (one-second bars: 2 ms and 140 ms), against about 0.5 s
to build them from the recording per request. 1.0.0's per-security covered took about 25 s
over such a day's four bar sizes, at query time.
Service¶
query_service.py handles a request in order. The path must be /v0/NAME with a known NAME.
Parameters come from the query string and, for POST, the form body (at most 64 KiB; the body
wins). The key is the basic-auth username (the password is ignored) or a Bearer token, and
schema.owner turns it into the owner ID. There is no key registry, so any well-formed
identity authenticates: a missing or malformed key is HTTP 400, not 401, and an identity with
no recordings gets empty tables (HTTP 404 from the dates endpoints). Quotas use the archive's
DynamoDB table and archive.quota: first query-ip-ADDRESS (120 requests a minute, keyed as
archive.address does, before the key is read, so requests without a valid key count too),
then query-OWNER (60 requests a minute) before the endpoint runs, and on the encoded result's
size query-bytes-OWNER (2 GiB per UTC day), query-bytes-ip-ADDRESS (4 GiB) and
query-bytes-all (20 GiB for everyone); over any is HTTP 429. Errors are {"detail": ...} with 400 (invalid input), 404 (no data, or no demo
copy) or 429.
A result of up to 4 MiB (INLINE: a function URL returns 6 MB, and base64 adds a third) is
the response body, with x-record-count: text for CSV and JSON, base64 for Parquet. A larger
one is written to results/OWNER/UUID.EXT and the response is a 303 to a 15-minute presigned
URL; the lifecycle removes it after a day. The endpoints are Databento's:
timeseries.get_range; metadata.list_datasets, list_schemas, list_fields (names only),
list_publishers, get_dataset_range (the first and last date with data) and
get_dataset_condition (each date available or missing with the merged clock windows
recorded, JST, up to a year); get_record_count (runs the query and counts its rows) and
get_cost (always 0). The function prints nothing.
Client¶
Historical(key=None, gateway=None) takes the key from identity.ensure() (warning when it makes one) and the gateway
from query_url. timeseries.get_range posts a form, asks for Parquet and returns a
DataStore with databento.DBNStore's conversions (to_df() indexed by ts_recv, or by ts_event where there
is none, with price_type, pretty_ts, map_symbols, tz and count; to_ndarray(), to_csv(),
to_json(), to_parquet(), to_file(), replay(), iteration and len()). The metadata calls are GETs, except get_record_count
and get_cost, which post the request. The key goes as the basic-auth username with an empty
password. A 303 is followed by hand with a plain request (_NoRedirect stops urllib doing it),
so the key never reaches the result's URL. brisk query posts the same get_range request
with the chosen encoding (CSV by default; Parquet needs --output).
Development and deployment¶
Tests and conventions are in CONTRIBUTING.md. Archive and cloud API
deployment, IAM scope, costs and removal are in infra/README.md.
Archive and cloud API changes reach clients only after infra/deploy.py runs.