briskapi 0.2.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {briskapi-0.2.1 → briskapi-0.3.0}/PKG-INFO +13 -8
  2. {briskapi-0.2.1 → briskapi-0.3.0}/README.md +12 -7
  3. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/__init__.py +1 -1
  4. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/cli.py +3 -1
  5. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/decoder/decoder.cjs +48 -9
  6. briskapi-0.3.0/briskapi/decoder/engineio.cjs +261 -0
  7. briskapi-0.3.0/briskapi/decoder/sbi.cjs +287 -0
  8. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/sbi.py +17 -3
  9. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi.egg-info/PKG-INFO +13 -8
  10. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi.egg-info/SOURCES.txt +1 -0
  11. {briskapi-0.2.1 → briskapi-0.3.0}/pyproject.toml +1 -1
  12. {briskapi-0.2.1 → briskapi-0.3.0}/tests/test_sbi.py +23 -0
  13. briskapi-0.2.1/briskapi/decoder/sbi.cjs +0 -144
  14. {briskapi-0.2.1 → briskapi-0.3.0}/LICENSE +0 -0
  15. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/LICENSE-pybrisk.txt +0 -0
  16. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/__main__.py +0 -0
  17. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/_archive.py +0 -0
  18. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/_live.py +0 -0
  19. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/_market.py +0 -0
  20. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/_recording.py +0 -0
  21. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/archive.json +0 -0
  22. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/decoder/assets.json +0 -0
  23. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/decoder/web.cjs +0 -0
  24. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/references/historical_mock.json +0 -0
  25. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/schema.py +0 -0
  26. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi/timing.py +0 -0
  27. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi.egg-info/dependency_links.txt +0 -0
  28. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi.egg-info/entry_points.txt +0 -0
  29. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi.egg-info/requires.txt +0 -0
  30. {briskapi-0.2.1 → briskapi-0.3.0}/briskapi.egg-info/top_level.txt +0 -0
  31. {briskapi-0.2.1 → briskapi-0.3.0}/setup.cfg +0 -0
  32. {briskapi-0.2.1 → briskapi-0.3.0}/tests/test_api.py +0 -0
  33. {briskapi-0.2.1 → briskapi-0.3.0}/tests/test_archive.py +0 -0
  34. {briskapi-0.2.1 → briskapi-0.3.0}/tests/test_package.py +0 -0
  35. {briskapi-0.2.1 → briskapi-0.3.0}/tests/test_timing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: briskapi
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Unofficial Python API, live feed and shared archive for BRiSK auction data
5
5
  License-Expression: MIT
6
6
  Project-URL: Source, https://github.com/honvl/BRiSKapi
@@ -126,11 +126,17 @@ again), `briskapi.NotFoundError`, `sbi.RateLimitError` and `sbi.APIError`.
126
126
  Requests are limited to one per second.
127
127
 
128
128
  The live feed runs SBI's own decoder under Node, downloaded with your session;
129
- no browser is involved. It hasn't yet been validated against a live SBI session,
130
- so it fails with an explicit error rather than guessing. Please report what you
131
- see. Your cookies go only to sbi.brisk.jp, and SBI market data never leaves
132
- your computer. With sharing on, a session contributes only a timing summary (see
133
- below).
129
+ no browser is involved. It follows the vendor client's connect sequence: it feeds
130
+ the decoder from the first frame, catches the snapshot up to the stream, forwards
131
+ the decoder's pings, watches the server heartbeat and joins SBI's Socket.IO
132
+ namespace when the server uses it. It hasn't yet been validated against a live SBI
133
+ session, and three wire details aren't public: the Socket.IO connect parameters,
134
+ the `startLive` payload and the catch-up request body. Set them with
135
+ `sbi.connect(profile={...})` and see what the server answers with
136
+ `trace_protocol=True` (every token redacted); until they are right it stops with an
137
+ explicit error rather than guessing. Please report what you see. Your cookies go
138
+ only to sbi.brisk.jp, and SBI market data never leaves your computer. With sharing
139
+ on, a session contributes only a timing summary (see below).
134
140
 
135
141
  ## API reference
136
142
 
@@ -165,7 +171,7 @@ recording once (about six seconds for the complete 420 MB demo).
165
171
 
166
172
  ```sh
167
173
  brisk live --web --codes 7203,6758 # one JSON object per quote update (--raw for vendor fields)
168
- brisk live --sbi --codes 7203 # SBI BRiSK; cookies from BRISK_SBI_COOKIES (JSON)
174
+ brisk live --sbi --codes 7203 # SBI BRiSK; cookies from BRISK_SBI_COOKIES (JSON); --trace-protocol shows the handshake, wire details in BRISK_SBI_PROFILE
169
175
  brisk record --web --output recordings/s1 # record a replay (shared if you agreed)
170
176
  brisk list --date 20210927 --source historical_mock
171
177
  brisk pull archive/20210927/SHA256 --output recordings/downloaded
@@ -207,7 +213,6 @@ API never asks: until you decide, sessions stay on your computer.
207
213
  - [PRIVACY.md](https://github.com/honvl/BRiSKapi/blob/main/PRIVACY.md): privacy policy
208
214
  - [CONTRIBUTING.md](https://github.com/honvl/BRiSKapi/blob/main/CONTRIBUTING.md): development, tests and releases
209
215
  - [tools/brisk_mock/README.md](https://github.com/honvl/BRiSKapi/blob/main/tools/brisk_mock/README.md): Rust collector, field definitions, timing and latency
210
- - [tools/brisk_mock/NAUTILUS_V2.md](https://github.com/honvl/BRiSKapi/blob/main/tools/brisk_mock/NAUTILUS_V2.md): NautilusTrader v2 integration
211
216
  - [infra/README.md](https://github.com/honvl/BRiSKapi/blob/main/infra/README.md): deploying your own archive
212
217
  - [THIRD_PARTY.md](https://github.com/honvl/BRiSKapi/blob/main/THIRD_PARTY.md): decoder, data and pybrisk attribution
213
218
 
@@ -99,11 +99,17 @@ again), `briskapi.NotFoundError`, `sbi.RateLimitError` and `sbi.APIError`.
99
99
  Requests are limited to one per second.
100
100
 
101
101
  The live feed runs SBI's own decoder under Node, downloaded with your session;
102
- no browser is involved. It hasn't yet been validated against a live SBI session,
103
- so it fails with an explicit error rather than guessing. Please report what you
104
- see. Your cookies go only to sbi.brisk.jp, and SBI market data never leaves
105
- your computer. With sharing on, a session contributes only a timing summary (see
106
- below).
102
+ no browser is involved. It follows the vendor client's connect sequence: it feeds
103
+ the decoder from the first frame, catches the snapshot up to the stream, forwards
104
+ the decoder's pings, watches the server heartbeat and joins SBI's Socket.IO
105
+ namespace when the server uses it. It hasn't yet been validated against a live SBI
106
+ session, and three wire details aren't public: the Socket.IO connect parameters,
107
+ the `startLive` payload and the catch-up request body. Set them with
108
+ `sbi.connect(profile={...})` and see what the server answers with
109
+ `trace_protocol=True` (every token redacted); until they are right it stops with an
110
+ explicit error rather than guessing. Please report what you see. Your cookies go
111
+ only to sbi.brisk.jp, and SBI market data never leaves your computer. With sharing
112
+ on, a session contributes only a timing summary (see below).
107
113
 
108
114
  ## API reference
109
115
 
@@ -138,7 +144,7 @@ recording once (about six seconds for the complete 420 MB demo).
138
144
 
139
145
  ```sh
140
146
  brisk live --web --codes 7203,6758 # one JSON object per quote update (--raw for vendor fields)
141
- brisk live --sbi --codes 7203 # SBI BRiSK; cookies from BRISK_SBI_COOKIES (JSON)
147
+ brisk live --sbi --codes 7203 # SBI BRiSK; cookies from BRISK_SBI_COOKIES (JSON); --trace-protocol shows the handshake, wire details in BRISK_SBI_PROFILE
142
148
  brisk record --web --output recordings/s1 # record a replay (shared if you agreed)
143
149
  brisk list --date 20210927 --source historical_mock
144
150
  brisk pull archive/20210927/SHA256 --output recordings/downloaded
@@ -180,7 +186,6 @@ API never asks: until you decide, sessions stay on your computer.
180
186
  - [PRIVACY.md](https://github.com/honvl/BRiSKapi/blob/main/PRIVACY.md): privacy policy
181
187
  - [CONTRIBUTING.md](https://github.com/honvl/BRiSKapi/blob/main/CONTRIBUTING.md): development, tests and releases
182
188
  - [tools/brisk_mock/README.md](https://github.com/honvl/BRiSKapi/blob/main/tools/brisk_mock/README.md): Rust collector, field definitions, timing and latency
183
- - [tools/brisk_mock/NAUTILUS_V2.md](https://github.com/honvl/BRiSKapi/blob/main/tools/brisk_mock/NAUTILUS_V2.md): NautilusTrader v2 integration
184
189
  - [infra/README.md](https://github.com/honvl/BRiSKapi/blob/main/infra/README.md): deploying your own archive
185
190
  - [THIRD_PARTY.md](https://github.com/honvl/BRiSKapi/blob/main/THIRD_PARTY.md): decoder, data and pybrisk attribution
186
191
 
@@ -18,7 +18,7 @@ from briskapi._live import Feed, connect as _connect, record as _record, stream
18
18
  from briskapi._market import Market, Ticker
19
19
  from briskapi._recording import JST, BriskError, NotFoundError, Recording, Table
20
20
 
21
- __version__ = '0.2.1'
21
+ __version__ = '0.3.0'
22
22
  __all__ = ['JST', 'Archive', 'BriskError', 'Feed', 'Market', 'NotFoundError', 'Recording', 'Table', 'Ticker',
23
23
  'connect', 'consent', 'current', 'load', 'pull', 'record', 'recordings', 'stream']
24
24
 
@@ -252,7 +252,7 @@ def live(args):
252
252
  warnings.simplefilter('always')
253
253
  if args.sbi: # Market data stays local; only a timing summary is contributed.
254
254
  sbi.login()
255
- feed = sbi.connect(codes=codes)
255
+ feed = sbi.connect(codes=codes, trace_protocol=args.trace_protocol)
256
256
  else:
257
257
  feed = connect(web=args.web, cache=args.cache, codes=codes, speed=args.speed, limit_frames=args.limit_frames)
258
258
  for warning in caught:
@@ -308,6 +308,8 @@ def _main(argv):
308
308
  p.add_argument('--raw', action='store_true', help='Vendor fields (price10, microseconds) instead of yen/ISO times')
309
309
  group.add_argument('--sbi', action='store_true',
310
310
  help='Live SBI BRiSK (experimental); cookies from BRISK_SBI_COOKIES or saved with sbi.login(remember=True)')
311
+ p.add_argument('--trace-protocol', action='store_true',
312
+ help='With --sbi: print the connection steps to stderr, every token redacted (wire details in BRISK_SBI_PROFILE)')
311
313
  p = sub.add_parser('upload', help='Contribute a prepared package'); p.add_argument('directory', type=Path)
312
314
  p = sub.add_parser('list', help='List published recordings'); p.add_argument('--date'); p.add_argument('--source', choices=['historical_mock','synthetic_test'])
313
315
  p = sub.add_parser('pull', help='Download and verify a recording'); p.add_argument('prefix'); p.add_argument('--output', type=Path, required=True)
@@ -54,6 +54,8 @@ function loadAssets(cache) {
54
54
 
55
55
  class Decoder {
56
56
  // options.protocolVersion: 16000 for the Next demo (default), 18000 for SBI BRiSK.
57
+ // options.callbacks: { send(Buffer), heartbeat(ns BigInt), frameNumbers(Array), basePrice(n),
58
+ // authError(), marketFinished() }, the vendor client's six WASM callbacks.
57
59
  static async create(assets, options = {}) {
58
60
  // Isolate the legacy glue's globals and process exception handlers. No UI,
59
61
  // network, filesystem access or account state is needed inside this VM.
@@ -72,14 +74,32 @@ class Decoder {
72
74
  return new Decoder(wasm, assets, options);
73
75
  }
74
76
 
75
- constructor(w, assets, { protocolVersion = manifest.protocol_version } = {}) {
77
+ constructor(w, assets, { protocolVersion = manifest.protocol_version, callbacks = {} } = {}) {
76
78
  this.w = w;
77
79
  this.authError = false;
80
+ this.marketFinished = false;
78
81
  this.initialFrames = null;
79
- const add = (fn, sig = 'viii') => w.addFunction(fn, sig);
80
- this.id = w._initialize(add(() => {}), add((id, p, count) => {
81
- this.initialFrames = Array.from(new Uint32Array(w.HEAPU8.buffer, p, count));
82
- }), add(() => {}), add(() => {}), add(() => { this.authError = true; }), add(() => {}), protocolVersion);
82
+ this.lastHeartbeat = null;
83
+ // The signatures are the vendor client's own (an indirect call with another type traps).
84
+ const add = (fn, sig) => w.addFunction(fn, sig);
85
+ this.id = w._initialize(
86
+ // send(id, ptr, len): bytes the client must write to the server. The WASM decides
87
+ // when (the vendor client has no ping timer of its own), so a host must forward them.
88
+ add((id, ptr, len) => callbacks.send?.(Buffer.from(w.HEAPU8.slice(ptr, ptr + len))), 'viii'),
89
+ // updateNumber(id, ptr, count): the first frame number of every issue on the stream.
90
+ add((id, p, count) => {
91
+ this.initialFrames = Array.from(new Uint32Array(w.HEAPU8.buffer, p, count));
92
+ callbacks.frameNumbers?.(this.initialFrames);
93
+ }, 'viii'),
94
+ // heartbeat(id, low, high): the server's clock, nanoseconds since the Unix epoch.
95
+ add((id, low, high) => {
96
+ this.lastHeartbeat = BigInt(low >>> 0) + (BigInt(high >>> 0) << 32n);
97
+ callbacks.heartbeat?.(this.lastHeartbeat);
98
+ }, 'viii'),
99
+ add((id, price) => callbacks.basePrice?.(price), 'vii'),
100
+ add(() => { this.authError = true; callbacks.authError?.(); }, 'vii'),
101
+ add(() => { this.marketFinished = true; callbacks.marketFinished?.(); }, 'vi'),
102
+ protocolVersion);
83
103
  this.buf = w._malloc(BUFFER);
84
104
  this.aux = w._malloc(64);
85
105
  this.push(assets['master.dat']);
@@ -152,13 +172,32 @@ class Decoder {
152
172
  start(frame) {
153
173
  this.feed(frame);
154
174
  if (!this.initialFrames) return false;
155
- if (this.initialFrames.length !== this.master.length) throw new Error('Missing frame-number bootstrap');
175
+ if (this.laggingIssues().length) throw new Error('Snapshot needs unavailable catch-up data');
176
+ this.begin();
177
+ return true;
178
+ }
179
+
180
+ // Frame number of every issue as the decoder holds it now (the snapshot's, before catch-up).
181
+ frameNumbers() {
156
182
  this.w._getFrameNumbers(this.id, this.buf, this.master.length);
157
- const current = new Uint32Array(this.w.HEAPU8.buffer, this.buf, this.master.length);
158
- if (this.initialFrames.some((n, i) => current[i] < n)) throw new Error('Snapshot needs unavailable catch-up data');
183
+ return Array.from(new Uint32Array(this.w.HEAPU8.buffer, this.buf, this.master.length));
184
+ }
185
+
186
+ // Issues whose snapshot is older than the stream's first frame: the ranges a live
187
+ // client must fetch before the state is consistent. Empty for the demo's cut recording.
188
+ laggingIssues() {
189
+ if (!this.initialFrames) throw new Error('Frame numbers not received yet');
190
+ if (this.initialFrames.length !== this.master.length) throw new Error('Missing frame-number bootstrap');
191
+ const current = this.frameNumbers();
192
+ const lagging = [];
193
+ this.initialFrames.forEach((to, issue_id) => { if (current[issue_id] < to) lagging.push({ issue_id, from: current[issue_id], to }); });
194
+ return lagging;
195
+ }
196
+
197
+ // Mark the API data complete and start tracing quote changes.
198
+ begin() {
159
199
  this.w._apiRecieved(this.id);
160
200
  this.trace = this.w._addTraceUpdate(this.id);
161
- return true;
162
201
  }
163
202
 
164
203
  changed() {
@@ -0,0 +1,261 @@
1
+ 'use strict';
2
+ // Link to a BRiSK market-data WebSocket.
3
+ //
4
+ // Two server dialects exist. The Next client talks plain binary frames over a native
5
+ // WebSocket. Upstream's SBI capture (pybrisk, 2026-03-11) shows SBI's web app wrapping
6
+ // the same frames in Engine.IO / Socket.IO: the server opens with an Engine.IO packet
7
+ // (`0{"sid":...,"pingInterval":...}`), the client joins the `/v2/user` namespace with a
8
+ // query and sends a `startLive` event, and only then do binary frames flow. The dialect
9
+ // is detected from the first message, so a plain server keeps working untouched.
10
+ //
11
+ // What is NOT public, and is therefore kept in the profile below rather than hard-coded:
12
+ // where each connect parameter comes from and what `startLive` carries. Run with
13
+ // `--trace-protocol` (secrets redacted) to see the server's side of a first attempt.
14
+
15
+ const MAX_TRACE = 300;
16
+
17
+ const ENGINE = { open: '0', close: '1', ping: '2', pong: '3', message: '4', noop: '6' };
18
+ const SOCKET = { connect: '0', disconnect: '1', event: '2', connectError: '4' };
19
+
20
+ const TOKEN = /v2\.local\.[A-Za-z0-9_\-.=]+/g;
21
+ const SECRET_PARAM = /\b(api_key|session|session_id|visitor_id|token|csrf_token|api_token|identity|sid)=(?!<redacted:)([^&\s",]+)/g;
22
+ const SECRET_FIELD = /("(?:sid|api_key|token|session|session_id|visitor_id|identity|api_token|csrf_token)"\s*:\s*")(?!<redacted:)([^"]+)(")/g;
23
+ const LONG_HEX = /\b[0-9a-f]{24,}\b/gi;
24
+
25
+ // Remove credentials from anything that is about to be printed.
26
+ function redact(text) {
27
+ const mask = value => `<redacted:${value.length}>`;
28
+ return String(text)
29
+ .replace(TOKEN, mask)
30
+ .replace(SECRET_PARAM, (_, key, value) => `${key}=${mask(value)}`)
31
+ .replace(SECRET_FIELD, (_, open, value, close) => open + mask(value) + close)
32
+ .replace(LONG_HEX, mask);
33
+ }
34
+
35
+ const SBI_PROFILE = Object.freeze({
36
+ namespace: '/v2/user',
37
+ // UNVERIFIED: upstream names these parameters but not their sources. `api_key` is
38
+ // probably the market token; the rest are plausible, not confirmed.
39
+ connectQuery: ctx => ({ api_key: ctx.marketToken, visitor_id: ctx.identity, session_id: ctx.wsSession,
40
+ tabId: ctx.tabId, url: `${ctx.origin}/` }),
41
+ // UNKNOWN: upstream elides the payload as `{...}`.
42
+ startLive: () => ({}),
43
+ // UNVERIFIED: Engine.IO 3 prefixes binary frames with a type byte; upstream decodes
44
+ // frames from byte 0, so the default is no prefix.
45
+ binaryPrefix: false,
46
+ });
47
+
48
+ const PROFILE_KEYS = new Set(['connectQuery', 'startLive', 'binaryPrefix', 'catchUp']);
49
+
50
+ // Overrides come from BRISK_SBI_PROFILE / --profile as JSON, so they are plain data.
51
+ function resolveProfile(overrides = {}) {
52
+ if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)) {
53
+ throw new Error('SBI profile must be a JSON object');
54
+ }
55
+ const unknown = Object.keys(overrides).filter(key => !PROFILE_KEYS.has(key));
56
+ if (unknown.length) throw new Error(`Unknown SBI profile keys: ${unknown.join(', ')}`);
57
+ return {
58
+ namespace: SBI_PROFILE.namespace,
59
+ connectQuery: ctx => ({ ...SBI_PROFILE.connectQuery(ctx), ...overrides.connectQuery }),
60
+ startLive: overrides.startLive === undefined ? SBI_PROFILE.startLive : () => overrides.startLive,
61
+ binaryPrefix: overrides.binaryPrefix ?? SBI_PROFILE.binaryPrefix,
62
+ catchUp: overrides.catchUp,
63
+ };
64
+ }
65
+
66
+ class Link {
67
+ // options: WebSocketImpl, url, headers, profile, context() -> Promise<object>, trace(line),
68
+ // onBinary(Buffer), onClose({code, reason}), onError(Error), connectTimeoutMs
69
+ constructor(options) {
70
+ this.o = { connectTimeoutMs: 15000, ...options };
71
+ this.mode = 'detect'; // detect | raw | engineio
72
+ this.state = 'connecting'; // engineio: connecting | namespace-requested | live
73
+ this.version = null;
74
+ this.finished = false;
75
+ this.timers = new Set();
76
+ const socket = this.socket = new this.o.WebSocketImpl(this.o.url, { headers: this.o.headers });
77
+ socket.binaryType = 'arraybuffer';
78
+ socket.addEventListener('message', event => this.guard(() => this.onMessage(event.data)));
79
+ socket.addEventListener('error', event =>
80
+ this.fail(new Error(`SBI BRiSK WebSocket error: ${event.message || 'connection failed'}`)));
81
+ socket.addEventListener('close', event => {
82
+ if (this.finished) return;
83
+ this.finish();
84
+ this.o.onClose({ code: event.code, reason: event.reason || '' });
85
+ });
86
+ }
87
+
88
+ trace(direction, text) {
89
+ if (!this.o.trace) return;
90
+ const line = redact(text);
91
+ this.o.trace(`${direction} ${line.length > MAX_TRACE ? `${line.slice(0, MAX_TRACE)}...(${line.length} chars)` : line}`);
92
+ }
93
+
94
+ guard(fn) {
95
+ try { fn(); } catch (error) { this.fail(error); }
96
+ }
97
+
98
+ // Timers stay referenced on purpose: a live session in progress must keep the process
99
+ // alive, and finish() clears every one of them.
100
+ timer(fn, ms, repeat = false) {
101
+ const handle = (repeat ? setInterval : setTimeout)(() => this.guard(fn), ms);
102
+ this.timers.add(handle);
103
+ return handle;
104
+ }
105
+
106
+ clear(handle) {
107
+ clearTimeout(handle); clearInterval(handle);
108
+ this.timers.delete(handle);
109
+ }
110
+
111
+ finish() {
112
+ this.finished = true;
113
+ for (const handle of this.timers) { clearTimeout(handle); clearInterval(handle); }
114
+ this.timers.clear();
115
+ }
116
+
117
+ fail(error) {
118
+ if (this.finished) return;
119
+ this.finish();
120
+ try { this.socket.close(); } catch { /* already closing */ }
121
+ this.o.onError(error);
122
+ }
123
+
124
+ // Bytes for the server: the WASM's own pings travel through here as raw binary frames.
125
+ send(bytes) {
126
+ this.trace('→', `binary ${bytes.length} bytes ${Buffer.from(bytes.subarray(0, 12)).toString('hex')}`);
127
+ this.socket.send(bytes);
128
+ }
129
+
130
+ sendText(text) {
131
+ this.trace('→', text);
132
+ this.socket.send(text);
133
+ }
134
+
135
+ close(code = 1000) {
136
+ try { this.socket.close(code); } catch { /* already closed */ }
137
+ }
138
+
139
+ onMessage(data) {
140
+ if (this.finished) return;
141
+ if (typeof data === 'string') return this.onText(data);
142
+ if (this.mode === 'detect') this.mode = 'raw';
143
+ let bytes = Buffer.from(data);
144
+ this.trace('←', `binary ${bytes.length} bytes ${bytes.subarray(0, 12).toString('hex')}`);
145
+ if (this.mode === 'engineio' && this.o.profile.binaryPrefix) {
146
+ if (bytes[0] !== 4) throw new Error(`Engine.IO binary frame without the message type byte (got 0x${bytes[0]?.toString(16)})`);
147
+ bytes = bytes.subarray(1);
148
+ }
149
+ this.o.onBinary(bytes);
150
+ }
151
+
152
+ onText(text) {
153
+ this.trace('←', text);
154
+ if (this.mode === 'detect') {
155
+ // A plain server's text is control chatter that carries no market data.
156
+ if (!/^0\{/.test(text)) { this.mode = 'raw'; return; }
157
+ this.mode = 'engineio';
158
+ }
159
+ if (this.mode === 'raw') return;
160
+ const body = text.slice(1);
161
+ switch (text[0]) {
162
+ case ENGINE.open: this.open(body); break;
163
+ case ENGINE.close: this.fail(new Error('Engine.IO: the server closed the session')); break;
164
+ case ENGINE.ping:
165
+ this.sendText(ENGINE.pong + body);
166
+ if (this.version === 4) this.watch();
167
+ break;
168
+ case ENGINE.pong:
169
+ if (this.pongTimer) { this.clear(this.pongTimer); this.pongTimer = null; }
170
+ break;
171
+ case ENGINE.message: this.socketIo(body); break;
172
+ case ENGINE.noop: break;
173
+ default: throw new Error(`Unknown Engine.IO packet type ${JSON.stringify(text[0])}`);
174
+ }
175
+ }
176
+
177
+ // Engine.IO 3 clients ping; Engine.IO 4 servers ping. The open packet tells them apart
178
+ // (version 4 adds maxPayload).
179
+ open(body) {
180
+ let info;
181
+ try { info = JSON.parse(body); } catch { throw new Error('Malformed Engine.IO open packet'); }
182
+ this.version = info.maxPayload === undefined ? 3 : 4;
183
+ this.pingInterval = Number(info.pingInterval) || 25000;
184
+ this.pingTimeout = Number(info.pingTimeout) || 5000;
185
+ if (this.version === 3) {
186
+ this.timer(() => {
187
+ this.sendText(ENGINE.ping);
188
+ this.pongTimer = this.timer(() => this.fail(new Error('Engine.IO ping timeout')), this.pingTimeout);
189
+ }, this.pingInterval, true);
190
+ } else {
191
+ this.watch();
192
+ }
193
+ this.o.context().then(ctx => this.guard(() => this.joinNamespace(ctx)), error => this.fail(error));
194
+ }
195
+
196
+ watch() {
197
+ if (this.watchdog) this.clear(this.watchdog);
198
+ this.watchdog = this.timer(() => this.fail(new Error('Engine.IO: the server stopped pinging')),
199
+ this.pingInterval + this.pingTimeout);
200
+ }
201
+
202
+ joinNamespace(ctx) {
203
+ if (this.finished) return;
204
+ this.ctx = ctx;
205
+ const { namespace, connectQuery } = this.o.profile;
206
+ const query = Object.entries(connectQuery(ctx)).filter(([, value]) => value !== undefined && value !== null)
207
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&');
208
+ this.state = 'namespace-requested';
209
+ this.sendText(`${ENGINE.message}${SOCKET.connect}${namespace}${query ? `?${query}` : ''}`);
210
+ this.connectTimer = this.timer(() => this.fail(new Error(
211
+ `Socket.IO namespace ${namespace} was not acknowledged within ${this.o.connectTimeoutMs / 1000}s`)),
212
+ this.o.connectTimeoutMs);
213
+ }
214
+
215
+ socketIo(body) {
216
+ const type = body[0];
217
+ let rest = body.slice(1);
218
+ let nsp = '/';
219
+ if (rest[0] === '/') {
220
+ const comma = rest.indexOf(',');
221
+ nsp = comma < 0 ? rest : rest.slice(0, comma);
222
+ rest = comma < 0 ? '' : rest.slice(comma + 1);
223
+ }
224
+ const name = nsp.split('?')[0];
225
+ const ours = name === this.o.profile.namespace;
226
+ switch (type) {
227
+ case SOCKET.connect:
228
+ if (ours && this.state === 'namespace-requested') this.startLive();
229
+ break;
230
+ case SOCKET.disconnect:
231
+ if (ours) this.fail(new Error(`Socket.IO namespace ${name} was closed by the server`));
232
+ break;
233
+ case SOCKET.connectError:
234
+ this.fail(new Error(`Socket.IO connect error on ${name}: ${redact(rest) || '(no detail)'}`));
235
+ break;
236
+ default: break; // events and acknowledgements carry nothing the host needs
237
+ }
238
+ }
239
+
240
+ startLive() {
241
+ this.clear(this.connectTimer);
242
+ this.state = 'live';
243
+ const { namespace, startLive } = this.o.profile;
244
+ this.sendText(`${ENGINE.message}${SOCKET.event}${namespace},${JSON.stringify(
245
+ ['userEvent', { name: 'startLive', data: startLive(this.ctx) }])}`);
246
+ }
247
+ }
248
+
249
+ // The vendor client closes the connection when its last heartbeat is older than 7 s, but
250
+ // only once it has seen one. Beats come from the WASM's heartbeat callback.
251
+ function heartbeatMonitor({ intervalMs = 7000, checkEveryMs = 1000, onFail, now = () => performance.now() }) {
252
+ let last = null;
253
+ const timer = setInterval(() => {
254
+ if (last !== null && now() - last > intervalMs) {
255
+ onFail(new Error(`SBI BRiSK connection check failure: no heartbeat for ${Math.round((now() - last) / 1000)}s`));
256
+ }
257
+ }, checkEveryMs);
258
+ return { beat() { last = now(); }, stop() { clearInterval(timer); } };
259
+ }
260
+
261
+ module.exports = { Link, SBI_PROFILE, resolveProfile, heartbeatMonitor, redact };
@@ -0,0 +1,287 @@
1
+ 'use strict';
2
+ // Experimental live host for SBI BRiSK (sbi.brisk.jp): the session's own WASM
3
+ // decoder under Node, fed from the authenticated WebSocket. No browser or Chrome
4
+ // DevTools. Emits the same JSON batches as decoder.cjs (source=sbi_live).
5
+ // Cookies come from BRISK_SBI_COOKIES (JSON object), never from the command line.
6
+ //
7
+ // It follows the vendor client's connect sequence (public Next demo bundle) and
8
+ // upstream's SBI research (pybrisk): feed the WASM from the first frame, wait for its
9
+ // frame numbers, catch the snapshot up to them, forward the WASM's own pings, and watch
10
+ // the server heartbeat. Three wire details are not public (the Socket.IO connect
11
+ // parameters, the `startLive` payload and the catch-up request body); they live in the
12
+ // profile (BRISK_SBI_PROFILE) and `--trace-protocol` shows what the server answers.
13
+ const crypto = require('node:crypto');
14
+ const { once } = require('node:events');
15
+ const { Decoder } = require('./decoder.cjs');
16
+ const { Link, resolveProfile, heartbeatMonitor, redact } = require('./engineio.cjs');
17
+
18
+ const ORIGIN = 'https://sbi.brisk.jp';
19
+ const PROTOCOL_VERSION = 18000;
20
+ const MAX_BYTES = 64 * 1024 * 1024;
21
+ // SBI's decoder must provide everything the host calls (the demo ABI minus portfolio).
22
+ const REQUIRED = ['_initialize', '_push', '_applyBasePriceQueue', '_stockCount', '_getStockMaster', '_unserialize',
23
+ '_getDate', '_getTime', '_pushWs', '_getFrameNumbers', '_apiRecieved', '_addTraceUpdate', '_getTrace',
24
+ '_clearTrace', '_getStockView', '_fitItaViewRowPrice10', '_getItaRows', '_malloc', 'addFunction'];
25
+
26
+ class Session {
27
+ constructor(cookies, fetchImpl = fetch) {
28
+ if (!cookies || typeof cookies !== 'object' || !Object.keys(cookies).length) {
29
+ throw new Error('Set BRISK_SBI_COOKIES to your SBI BRiSK session cookies (JSON object)');
30
+ }
31
+ this.cookie = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; ');
32
+ this.fetch = fetchImpl;
33
+ this.token = null;
34
+ }
35
+
36
+ get(path, kind = 'json') {
37
+ return this.request(path, { kind });
38
+ }
39
+
40
+ async request(path, { method = 'GET', body, contentType, kind = 'json' } = {}) {
41
+ const headers = { cookie: this.cookie };
42
+ if (this.token) headers.authorization = `Bearer ${this.token}`;
43
+ if (contentType) headers['content-type'] = contentType;
44
+ // Redirects mean a login page; following them would forward credentials elsewhere.
45
+ const response = await this.fetch(new URL(path, ORIGIN), { method, headers, body, redirect: 'manual', signal: AbortSignal.timeout(30000) });
46
+ if ([301, 302, 303, 307, 308, 401, 403].includes(response.status) || response.type === 'opaqueredirect') {
47
+ throw new Error('SBI BRiSK session expired or invalid; log in again');
48
+ }
49
+ if (!response.ok) {
50
+ const error = new Error(`SBI BRiSK ${path}: HTTP ${response.status}`);
51
+ error.status = response.status;
52
+ error.reason = response.headers?.get?.('x-error-reason') ?? null;
53
+ throw error;
54
+ }
55
+ const data = Buffer.from(await response.arrayBuffer());
56
+ if (data.length > MAX_BYTES) throw new Error(`SBI BRiSK ${path}: response too large`);
57
+ return kind === 'json' ? JSON.parse(data) : kind === 'text' ? data.toString('utf8') : data;
58
+ }
59
+ }
60
+
61
+ // How the catch-up request is written. UNVERIFIED: the vendor client holds
62
+ // {issueCodeIdx, from, to} for every issue whose snapshot is behind the stream, but the
63
+ // body it sends is not public, so no format is used unless the profile names one.
64
+ const CATCH_UP_FORMATS = {
65
+ 'json-vendor': issues => ({ contentType: 'application/json',
66
+ body: JSON.stringify(issues.map(i => ({ issueCodeIdx: i.issue_id, from: i.from, to: i.to }))) }),
67
+ };
68
+
69
+ // Fetch the frames between the snapshot and the stream's first frame. Mirrors the vendor
70
+ // client: up to five retries, but never for an expired session or a snapshot that is too old.
71
+ async function fetchCatchUp(session, { app, issues, format, retries = 5, backoffMs = n => 1000 + 1000 * n, trace }) {
72
+ const build = CATCH_UP_FORMATS[format];
73
+ if (!format || !build) {
74
+ throw new Error(`The snapshot is ${issues.length} issues behind the stream, so it must be caught up with `
75
+ + 'POST /api/stocks_update, but the request body is not public'
76
+ + (format ? ` and "${format}" is not a known format (known: ${Object.keys(CATCH_UP_FORMATS).join(', ')})`
77
+ : '. Name a format in the profile (catchUp), for example {"catchUp":"json-vendor"}, and run with --trace-protocol'));
78
+ }
79
+ const { contentType, body } = build(issues);
80
+ const path = `/api/stocks_update/${encodeURIComponent(app.series)}?date=${encodeURIComponent(app.date)}`;
81
+ for (let attempt = 0; ; attempt++) {
82
+ try {
83
+ trace?.(`catch-up: POST ${path} (${issues.length} issues, ${body.length} bytes, format ${format})`);
84
+ return await session.request(path, { method: 'POST', body, contentType, kind: 'bytes' });
85
+ } catch (error) {
86
+ if (error.reason === 'too-old') {
87
+ throw new Error('SBI BRiSK says the snapshot is too old to catch up (x-error-reason: too-old); restart to load a fresh one');
88
+ }
89
+ if (/session expired/.test(error.message) || attempt >= retries) {
90
+ throw new Error(`Catch-up failed: ${error.message}${error.status ? ` (the server rejected format "${format}")` : ''}`);
91
+ }
92
+ trace?.(`catch-up attempt ${attempt + 1} failed: ${error.message}`);
93
+ await new Promise(resolve => setTimeout(resolve, backoffMs(attempt)));
94
+ }
95
+ }
96
+ }
97
+
98
+ // The decoder is served to logged-in sessions only and changes with SBI releases,
99
+ // so it is located through the app's own bundles rather than pinned.
100
+ async function decoderAssets(session) {
101
+ const page = await session.get('/', 'text');
102
+ const scripts = [...page.matchAll(/<script[^>]+src="(\/[^"]+\.js)"/g)].map(m => m[1]);
103
+ for (const script of scripts) {
104
+ const source = await session.get(script, 'text');
105
+ const js = source.match(/["'`](\/?assets\/wasm\/fita[\w.-]*\.js)["'`]/);
106
+ const wasm = source.match(/["'`](\/?assets\/wasm\/fita[\w.-]*\.wasm)["'`]/);
107
+ if (js && wasm) {
108
+ const assets = { 'fita.js': await session.get('/' + js[1].replace(/^\//, ''), 'bytes'),
109
+ 'fita.wasm': await session.get('/' + wasm[1].replace(/^\//, ''), 'bytes') };
110
+ return { assets, paths: [js[1], wasm[1]] };
111
+ }
112
+ }
113
+ throw new Error('SBI BRiSK decoder not found in the app bundles; the site layout may have changed');
114
+ }
115
+
116
+ function checkAbi(wasm) {
117
+ const missing = REQUIRED.filter(name => typeof wasm[name] !== 'function');
118
+ if (missing.length) throw new Error(`SBI BRiSK decoder changed; missing ${missing.join(', ')}`);
119
+ }
120
+
121
+ async function live({ cookies, codes = [], emit, fetchImpl = fetch, WebSocketImpl = WebSocket, startTimeoutMs = 60000,
122
+ protocolVersion = PROTOCOL_VERSION, profile: overrides = {}, trace = null, catchUp = null, heartbeatMs = 7000,
123
+ connectTimeoutMs = 15000, catchUpBackoffMs }) {
124
+ const profile = resolveProfile(overrides);
125
+ const session = new Session(cookies, fetchImpl);
126
+ const began = performance.now();
127
+ const frontend = await session.get('/api/frontend/boot');
128
+ session.token = frontend.api_token;
129
+ const app = await session.get('/api/app/boot');
130
+ trace?.(`boot: date ${app.date}, series ${app.series}, ws_url ${redact(app.ws_url)}`);
131
+ const { assets, paths } = await decoderAssets(session);
132
+ assets['master.dat'] = await session.get(`/api/master/${encodeURIComponent(app.master)}`, 'bytes');
133
+ assets['snapshot.dat'] = await session.get(`/api/snapshot/${encodeURIComponent(app.snapshot)}`, 'bytes');
134
+
135
+ // The WASM calls back into the host; these are the vendor client's six callbacks.
136
+ let link = null, failure = null, marketFinished = false, settle;
137
+ const ended = new Promise(resolve => { settle = resolve; });
138
+ const fail = error => {
139
+ failure = failure || error;
140
+ link?.close();
141
+ settle({ code: -1, reason: '' });
142
+ };
143
+ let monitor = null, timer = null; // started with the link and always stopped, so a failure cannot leave the process hanging
144
+ const decoder = await Decoder.create(assets, { protocolVersion, check: checkAbi, callbacks: {
145
+ send: bytes => { if (link) link.send(bytes); },
146
+ heartbeat: () => monitor?.beat(),
147
+ authError: () => fail(new Error('SBI BRiSK reports another WebSocket session for this user (only one is allowed); close the other one')),
148
+ marketFinished: () => { marketFinished = true; },
149
+ } });
150
+ const selected = new Set(codes);
151
+ const master = decoder.master.filter(m => !selected.size || selected.has(m.code));
152
+ const missing = codes.filter(code => !master.some(m => m.code === code));
153
+ if (missing.length) throw new Error(`Codes not in the SBI master: ${missing.join(',')}`);
154
+ const ids = new Set(master.map(m => m.issue_id));
155
+ const input_transport = { kind: 'sbi_websocket', origin: ORIGIN + '/', series: app.series,
156
+ decoder: paths, decoder_sha256: crypto.createHash('sha256').update(assets['fita.wasm']).digest('hex'),
157
+ setup_ms: performance.now() - began };
158
+
159
+ const requestCatchUp = catchUp || (issues => fetchCatchUp(session, { app, issues, format: profile.catchUp,
160
+ backoffMs: catchUpBackoffMs, trace }));
161
+ const wsSession = new URL(app.ws_url, ORIGIN).searchParams.get('session');
162
+ // Only a Socket.IO server needs these; a plain WebSocket never asks for the market token.
163
+ const context = async () => {
164
+ let marketToken;
165
+ try { marketToken = (await session.get('/api/app/market-token')).token; } catch (error) {
166
+ throw new Error(`app/market-token failed: ${error.message}`);
167
+ }
168
+ return { marketToken, identity: frontend.identity, wsSession, tabId: crypto.randomUUID(), origin: ORIGIN };
169
+ };
170
+
171
+ let seq = 0, frames = 0, updates = 0, started = false, starting = false, work = Promise.resolve();
172
+ const start = performance.now();
173
+
174
+ const finishStart = caughtUp => {
175
+ clearTimeout(timer);
176
+ decoder.begin();
177
+ const now = decoder.time();
178
+ const quotes = master.map(m => decoder.quote(m.issue_id));
179
+ // The stock view layout is unverified for SBI's build: refuse implausible values.
180
+ if (quotes.some(q => q.frame > q.max_frame || q.source_time_us > now)) {
181
+ throw new Error(`SBI BRiSK stock view layout not recognised (ohlcLength=${decoder.ohlc})`);
182
+ }
183
+ started = true;
184
+ const batch = { type: 'bootstrap', seq: seq++, source: 'sbi_live', trading_date: String(app.date).replaceAll('-', ''),
185
+ input_transport: { ...input_transport, dialect: link.mode, engineio: link.version, caught_up_issues: caughtUp },
186
+ source_timestamp_origin: 'brisk_decoder_unverified', exchange_delay_ms: null,
187
+ source_time_us: now, market_issue_count: decoder.master.length, master, quotes };
188
+ work = work.then(() => failure || emit(batch)).catch(fail);
189
+ };
190
+
191
+ // The snapshot is normally older than the stream's first frame. The vendor client feeds
192
+ // frames to the WASM at once and fetches the missing range meanwhile; so do we.
193
+ const begin = () => {
194
+ const lagging = decoder.laggingIssues();
195
+ if (!lagging.length) return finishStart(0);
196
+ trace?.(`catch-up needed for ${lagging.length} issues`);
197
+ work = work.then(async () => {
198
+ if (failure) return;
199
+ decoder.push(await requestCatchUp(lagging));
200
+ const left = decoder.laggingIssues();
201
+ if (left.length) {
202
+ throw new Error(`Catch-up left ${left.length} issues behind the stream (first: issue ${left[0].issue_id}, `
203
+ + `frame ${left[0].from}, stream starts at ${left[0].to})`);
204
+ }
205
+ finishStart(lagging.length);
206
+ }).catch(fail);
207
+ };
208
+
209
+ const handle = async (frame, received_unix_ms) => {
210
+ const t0 = process.hrtime.bigint();
211
+ decoder.feed(frame);
212
+ const quotes = decoder.changed().filter(id => ids.has(id)).map(id => decoder.quote(id));
213
+ updates += quotes.length;
214
+ await emit({ type: 'quotes', seq: seq++, source_time_us: decoder.time(), received_unix_ms,
215
+ decode_ns: Number(process.hrtime.bigint() - t0), replay_lateness_ms: null, quotes });
216
+ };
217
+
218
+ const onFrame = frame => {
219
+ const received_unix_ms = Date.now();
220
+ frames++;
221
+ if (started) {
222
+ work = work.then(() => failure || handle(frame, received_unix_ms)).catch(fail);
223
+ return;
224
+ }
225
+ try {
226
+ decoder.feed(frame);
227
+ if (!starting && decoder.initialFrames) { starting = true; begin(); }
228
+ } catch (error) { fail(error); }
229
+ };
230
+
231
+ try {
232
+ monitor = heartbeatMonitor({ intervalMs: heartbeatMs, checkEveryMs: Math.min(1000, heartbeatMs / 4), onFail: fail });
233
+ timer = setTimeout(() => fail(new Error(`SBI BRiSK stream did not initialize within ${startTimeoutMs / 1000}s; `
234
+ + 'no first frame numbers arrived (run with --trace-protocol to see what the server sent)')), startTimeoutMs);
235
+ link = new Link({ WebSocketImpl, url: new URL(app.ws_url, ORIGIN.replace('https:', 'wss:')),
236
+ headers: { cookie: session.cookie }, profile, context, trace, connectTimeoutMs, onBinary: onFrame,
237
+ onClose: closed => settle(closed), onError: error => { failure = failure || error; settle({ code: -1, reason: '' }); } });
238
+
239
+ const closed = await ended;
240
+ // A catch-up that finishes extends the chain (it queues the bootstrap), so wait until it stops growing.
241
+ for (let pending = null; pending !== work;) { pending = work; await pending; }
242
+ if (failure) throw failure;
243
+ if ((closed.code !== 1000 && !marketFinished) || !started) {
244
+ throw new Error(`SBI BRiSK stream closed (${closed.code} ${closed.reason || ''})`.trim());
245
+ }
246
+ const summary = { type: 'end', seq, source_time_us: decoder.time(), frames, quote_updates: updates,
247
+ replay_wall_ms: performance.now() - start };
248
+ await emit(summary);
249
+ return summary;
250
+ } finally {
251
+ clearTimeout(timer);
252
+ monitor?.stop();
253
+ link?.close();
254
+ }
255
+ }
256
+
257
+ const USAGE = 'Usage: BRISK_SBI_COOKIES=... sbi.cjs [--codes 7203,6758] [--trace-protocol]';
258
+
259
+ function parseArgs(argv) {
260
+ const options = { codes: [], trace: false };
261
+ for (let i = 0; i < argv.length; i++) {
262
+ if (argv[i] === '--codes' && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && !options.codesSet) {
263
+ options.codes = argv[++i].split(','); options.codesSet = true;
264
+ } else if (argv[i] === '--trace-protocol' && !options.trace) {
265
+ options.trace = true;
266
+ } else throw new Error(USAGE);
267
+ }
268
+ return options;
269
+ }
270
+
271
+ async function main(argv, env = process.env) {
272
+ const { codes, trace } = parseArgs(argv);
273
+ let profile = {};
274
+ if (env.BRISK_SBI_PROFILE) {
275
+ try { profile = JSON.parse(env.BRISK_SBI_PROFILE); } catch { throw new Error('BRISK_SBI_PROFILE is not valid JSON'); }
276
+ }
277
+ await live({ cookies: JSON.parse(env.BRISK_SBI_COOKIES || 'null'), codes, profile,
278
+ trace: trace ? line => process.stderr.write(`[sbi protocol] ${line}\n`) : null,
279
+ emit: async record => {
280
+ if (!process.stdout.write(JSON.stringify(record) + '\n')) await once(process.stdout, 'drain');
281
+ } });
282
+ }
283
+
284
+ module.exports = { Session, decoderAssets, checkAbi, live, main, fetchCatchUp, CATCH_UP_FORMATS, PROTOCOL_VERSION, REQUIRED };
285
+ if (require.main === module) main(process.argv.slice(2)).catch(error => {
286
+ console.error(error.message); process.exitCode = 1;
287
+ });
@@ -241,11 +241,21 @@ def _default() -> Client:
241
241
  return _client
242
242
 
243
243
 
244
- def connect(codes=None, history=False, node='node', timeout=120, contribute=None):
244
+ def connect(codes=None, history=False, node='node', timeout=120, contribute=None, trace_protocol=False, profile=None):
245
245
  """Experimental live SBI BRiSK feed: SBI's own WASM decoder under Node, never Chrome.
246
246
 
247
247
  Returns a briskapi.Feed (the default source for briskapi.Ticker and Market).
248
- The SBI live protocol has not been validated end to end; failures are explicit.
248
+ The host follows the vendor client's connect sequence: it feeds the WASM from the
249
+ first frame, catches the snapshot up to the stream, forwards the WASM's pings and
250
+ watches the server heartbeat. It also joins SBI's Socket.IO namespace when the
251
+ server speaks it. Three wire details are not public, so they can be set with
252
+ `profile` (a dict, sent through the environment because it may name tokens):
253
+
254
+ profile={"connectQuery": {"_v": "..."}, "startLive": {...}, "catchUp": "json-vendor"}
255
+
256
+ `trace_protocol=True` prints the connection steps to stderr with every token
257
+ redacted, to see what the server answers. The protocol has not been validated end to
258
+ end against a live session; failures are explicit.
249
259
  Market data never leaves your computer. With sharing on (briskapi.consent), a
250
260
  timing-only summary is contributed when the session ends; contribute=False
251
261
  keeps even that local.
@@ -256,8 +266,12 @@ def connect(codes=None, history=False, node='node', timeout=120, contribute=None
256
266
  command = [node, str(DECODER)]
257
267
  if codes:
258
268
  command += ['--codes', codes if isinstance(codes, str) else ','.join(map(str, codes))]
259
- # Cookies travel in the environment, never on the command line (visible to other users).
269
+ if trace_protocol:
270
+ command.append('--trace-protocol')
271
+ # Cookies and the profile travel in the environment, never on the command line (visible to other users).
260
272
  env = {**os.environ, 'BRISK_SBI_COOKIES': json.dumps(session.cookies)}
273
+ if profile:
274
+ env['BRISK_SBI_PROFILE'] = json.dumps(profile)
261
275
  feed = Feed(command=command, env=env, history=history, contribute=contribute, timing='sbi_live')
262
276
  try:
263
277
  return load(feed.ready(timeout))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: briskapi
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Unofficial Python API, live feed and shared archive for BRiSK auction data
5
5
  License-Expression: MIT
6
6
  Project-URL: Source, https://github.com/honvl/BRiSKapi
@@ -126,11 +126,17 @@ again), `briskapi.NotFoundError`, `sbi.RateLimitError` and `sbi.APIError`.
126
126
  Requests are limited to one per second.
127
127
 
128
128
  The live feed runs SBI's own decoder under Node, downloaded with your session;
129
- no browser is involved. It hasn't yet been validated against a live SBI session,
130
- so it fails with an explicit error rather than guessing. Please report what you
131
- see. Your cookies go only to sbi.brisk.jp, and SBI market data never leaves
132
- your computer. With sharing on, a session contributes only a timing summary (see
133
- below).
129
+ no browser is involved. It follows the vendor client's connect sequence: it feeds
130
+ the decoder from the first frame, catches the snapshot up to the stream, forwards
131
+ the decoder's pings, watches the server heartbeat and joins SBI's Socket.IO
132
+ namespace when the server uses it. It hasn't yet been validated against a live SBI
133
+ session, and three wire details aren't public: the Socket.IO connect parameters,
134
+ the `startLive` payload and the catch-up request body. Set them with
135
+ `sbi.connect(profile={...})` and see what the server answers with
136
+ `trace_protocol=True` (every token redacted); until they are right it stops with an
137
+ explicit error rather than guessing. Please report what you see. Your cookies go
138
+ only to sbi.brisk.jp, and SBI market data never leaves your computer. With sharing
139
+ on, a session contributes only a timing summary (see below).
134
140
 
135
141
  ## API reference
136
142
 
@@ -165,7 +171,7 @@ recording once (about six seconds for the complete 420 MB demo).
165
171
 
166
172
  ```sh
167
173
  brisk live --web --codes 7203,6758 # one JSON object per quote update (--raw for vendor fields)
168
- brisk live --sbi --codes 7203 # SBI BRiSK; cookies from BRISK_SBI_COOKIES (JSON)
174
+ brisk live --sbi --codes 7203 # SBI BRiSK; cookies from BRISK_SBI_COOKIES (JSON); --trace-protocol shows the handshake, wire details in BRISK_SBI_PROFILE
169
175
  brisk record --web --output recordings/s1 # record a replay (shared if you agreed)
170
176
  brisk list --date 20210927 --source historical_mock
171
177
  brisk pull archive/20210927/SHA256 --output recordings/downloaded
@@ -207,7 +213,6 @@ API never asks: until you decide, sessions stay on your computer.
207
213
  - [PRIVACY.md](https://github.com/honvl/BRiSKapi/blob/main/PRIVACY.md): privacy policy
208
214
  - [CONTRIBUTING.md](https://github.com/honvl/BRiSKapi/blob/main/CONTRIBUTING.md): development, tests and releases
209
215
  - [tools/brisk_mock/README.md](https://github.com/honvl/BRiSKapi/blob/main/tools/brisk_mock/README.md): Rust collector, field definitions, timing and latency
210
- - [tools/brisk_mock/NAUTILUS_V2.md](https://github.com/honvl/BRiSKapi/blob/main/tools/brisk_mock/NAUTILUS_V2.md): NautilusTrader v2 integration
211
216
  - [infra/README.md](https://github.com/honvl/BRiSKapi/blob/main/infra/README.md): deploying your own archive
212
217
  - [THIRD_PARTY.md](https://github.com/honvl/BRiSKapi/blob/main/THIRD_PARTY.md): decoder, data and pybrisk attribution
213
218
 
@@ -21,6 +21,7 @@ briskapi.egg-info/requires.txt
21
21
  briskapi.egg-info/top_level.txt
22
22
  briskapi/decoder/assets.json
23
23
  briskapi/decoder/decoder.cjs
24
+ briskapi/decoder/engineio.cjs
24
25
  briskapi/decoder/sbi.cjs
25
26
  briskapi/decoder/web.cjs
26
27
  briskapi/references/historical_mock.json
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "briskapi"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Unofficial Python API, live feed and shared archive for BRiSK auction data"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -203,6 +203,23 @@ def test_live_feed_via_node(fake_host, capfd):
203
203
  assert capfd.readouterr().err.strip() == '["--codes","7203"]'
204
204
 
205
205
 
206
+ def test_protocol_options_reach_the_host_without_putting_the_profile_in_argv(tmp_path, monkeypatch, capfd):
207
+ script = tmp_path / 'sbi_options.cjs'
208
+ script.write_text(
209
+ "console.error(JSON.stringify({argv: process.argv.slice(2), profile: process.env.BRISK_SBI_PROFILE || null}));\n"
210
+ f"for (const b of {json.dumps(sbi_session())}) console.log(JSON.stringify(b));\n")
211
+ monkeypatch.setattr(sbi, 'DECODER', script)
212
+ sbi.login({'session_bfaf77a2': 'v'})
213
+ profile = {'connectQuery': {'_v': 'build-9'}, 'catchUp': 'json-vendor'}
214
+ sbi.connect(codes=['7203'], trace_protocol=True, profile=profile).wait()
215
+ seen = json.loads(capfd.readouterr().err.strip())
216
+ assert seen['argv'] == ['--codes', '7203', '--trace-protocol']
217
+ assert json.loads(seen['profile']) == profile
218
+ assert 'build-9' not in ' '.join(seen['argv'])
219
+ sbi.connect(codes=['7203']).wait()
220
+ assert json.loads(capfd.readouterr().err.strip()) == {'argv': ['--codes', '7203'], 'profile': None}
221
+
222
+
206
223
  def test_live_feed_failure_closes(fake_host, tmp_path, monkeypatch):
207
224
  sbi.login({'session_bfaf77a2': 'wrong'})
208
225
  with pytest.raises(briskapi.BriskError, match='before bootstrap'):
@@ -224,6 +241,12 @@ def test_cli_live_sbi(fake_host, monkeypatch, capsys):
224
241
  assert not {'quotes', 'master', 'code', 'price'} & set(json.dumps(sent[0]).replace('"', ' ').split())
225
242
 
226
243
 
244
+ def test_cli_trace_protocol_reaches_the_sbi_host(fake_host, monkeypatch, capfd):
245
+ monkeypatch.setenv('BRISK_SBI_COOKIES', '{"session_bfaf77a2": "v"}')
246
+ cli.main(['live', '--sbi', '--codes', '7203', '--trace-protocol'])
247
+ assert capfd.readouterr().err.count('["--codes","7203","--trace-protocol"]') == 1
248
+
249
+
227
250
  def test_sbi_feed_contributes_timing_only(fake_host, monkeypatch):
228
251
  sent = []
229
252
  monkeypatch.setattr(cli, 'contribute_timing', lambda report, url: sent.append((report, url)) or {'status': 'published'})
@@ -1,144 +0,0 @@
1
- 'use strict';
2
- // Experimental live host for SBI BRiSK (sbi.brisk.jp): the session's own WASM
3
- // decoder under Node, fed from the authenticated WebSocket. No browser or Chrome
4
- // DevTools. Emits the same JSON batches as decoder.cjs (source=sbi_live).
5
- // Cookies come from BRISK_SBI_COOKIES (JSON object), never from the command line.
6
- const crypto = require('node:crypto');
7
- const { once } = require('node:events');
8
- const { Decoder } = require('./decoder.cjs');
9
-
10
- const ORIGIN = 'https://sbi.brisk.jp';
11
- const PROTOCOL_VERSION = 18000;
12
- const MAX_BYTES = 64 * 1024 * 1024;
13
- // SBI's decoder must provide everything the host calls (the demo ABI minus portfolio).
14
- const REQUIRED = ['_initialize', '_push', '_applyBasePriceQueue', '_stockCount', '_getStockMaster', '_unserialize',
15
- '_getDate', '_getTime', '_pushWs', '_getFrameNumbers', '_apiRecieved', '_addTraceUpdate', '_getTrace',
16
- '_clearTrace', '_getStockView', '_fitItaViewRowPrice10', '_getItaRows', '_malloc', 'addFunction'];
17
-
18
- class Session {
19
- constructor(cookies, fetchImpl = fetch) {
20
- if (!cookies || typeof cookies !== 'object' || !Object.keys(cookies).length) {
21
- throw new Error('Set BRISK_SBI_COOKIES to your SBI BRiSK session cookies (JSON object)');
22
- }
23
- this.cookie = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; ');
24
- this.fetch = fetchImpl;
25
- this.token = null;
26
- }
27
-
28
- async get(path, kind = 'json') {
29
- const headers = { cookie: this.cookie };
30
- if (this.token) headers.authorization = `Bearer ${this.token}`;
31
- // Redirects mean a login page; following them would forward credentials elsewhere.
32
- const response = await this.fetch(new URL(path, ORIGIN), { headers, redirect: 'manual', signal: AbortSignal.timeout(30000) });
33
- if ([301, 302, 303, 307, 308, 401, 403].includes(response.status) || response.type === 'opaqueredirect') {
34
- throw new Error('SBI BRiSK session expired or invalid; log in again');
35
- }
36
- if (!response.ok) throw new Error(`SBI BRiSK ${path}: HTTP ${response.status}`);
37
- const data = Buffer.from(await response.arrayBuffer());
38
- if (data.length > MAX_BYTES) throw new Error(`SBI BRiSK ${path}: response too large`);
39
- return kind === 'json' ? JSON.parse(data) : kind === 'text' ? data.toString('utf8') : data;
40
- }
41
- }
42
-
43
- // The decoder is served to logged-in sessions only and changes with SBI releases,
44
- // so it is located through the app's own bundles rather than pinned.
45
- async function decoderAssets(session) {
46
- const page = await session.get('/', 'text');
47
- const scripts = [...page.matchAll(/<script[^>]+src="(\/[^"]+\.js)"/g)].map(m => m[1]);
48
- for (const script of scripts) {
49
- const source = await session.get(script, 'text');
50
- const js = source.match(/["'`](\/?assets\/wasm\/fita[\w.-]*\.js)["'`]/);
51
- const wasm = source.match(/["'`](\/?assets\/wasm\/fita[\w.-]*\.wasm)["'`]/);
52
- if (js && wasm) {
53
- const assets = { 'fita.js': await session.get('/' + js[1].replace(/^\//, ''), 'bytes'),
54
- 'fita.wasm': await session.get('/' + wasm[1].replace(/^\//, ''), 'bytes') };
55
- return { assets, paths: [js[1], wasm[1]] };
56
- }
57
- }
58
- throw new Error('SBI BRiSK decoder not found in the app bundles; the site layout may have changed');
59
- }
60
-
61
- function checkAbi(wasm) {
62
- const missing = REQUIRED.filter(name => typeof wasm[name] !== 'function');
63
- if (missing.length) throw new Error(`SBI BRiSK decoder changed; missing ${missing.join(', ')}`);
64
- }
65
-
66
- async function live({ cookies, codes = [], emit, fetchImpl = fetch, WebSocketImpl = WebSocket, startTimeoutMs = 60000,
67
- protocolVersion = PROTOCOL_VERSION }) {
68
- const session = new Session(cookies, fetchImpl);
69
- const began = performance.now();
70
- session.token = (await session.get('/api/frontend/boot')).api_token;
71
- const app = await session.get('/api/app/boot');
72
- const { assets, paths } = await decoderAssets(session);
73
- assets['master.dat'] = await session.get(`/api/master/${encodeURIComponent(app.master)}`, 'bytes');
74
- assets['snapshot.dat'] = await session.get(`/api/snapshot/${encodeURIComponent(app.snapshot)}`, 'bytes');
75
- const decoder = await Decoder.create(assets, { protocolVersion, check: checkAbi });
76
- const selected = new Set(codes);
77
- const master = decoder.master.filter(m => !selected.size || selected.has(m.code));
78
- const missing = codes.filter(code => !master.some(m => m.code === code));
79
- if (missing.length) throw new Error(`Codes not in the SBI master: ${missing.join(',')}`);
80
- const ids = new Set(master.map(m => m.issue_id));
81
- const input_transport = { kind: 'sbi_websocket', origin: ORIGIN + '/', series: app.series,
82
- decoder: paths, decoder_sha256: crypto.createHash('sha256').update(assets['fita.wasm']).digest('hex'),
83
- setup_ms: performance.now() - began };
84
-
85
- const socket = new WebSocketImpl(new URL(app.ws_url, ORIGIN.replace('https:', 'wss:')), { headers: { cookie: session.cookie } });
86
- socket.binaryType = 'arraybuffer';
87
- let seq = 0, frames = 0, updates = 0, started = false, work = Promise.resolve(), failure = null;
88
- const start = performance.now();
89
- const timer = setTimeout(() => fail(new Error('SBI BRiSK stream did not initialize; a snapshot catch-up may be required')), startTimeoutMs);
90
- const fail = error => { failure = failure || error; socket.close(); };
91
- const handle = async data => {
92
- const received_unix_ms = Date.now();
93
- const frame = Buffer.from(data);
94
- frames++;
95
- if (!started) {
96
- // Until the decoder reports initial frame numbers there is no consistent state to publish.
97
- if (!decoder.start(frame)) return;
98
- started = true; clearTimeout(timer);
99
- const now = decoder.time();
100
- const quotes = master.map(m => decoder.quote(m.issue_id));
101
- // The stock view layout is unverified for SBI's build: refuse implausible values.
102
- if (quotes.some(q => q.frame > q.max_frame || q.source_time_us > now)) {
103
- throw new Error(`SBI BRiSK stock view layout not recognised (ohlcLength=${decoder.ohlc})`);
104
- }
105
- return emit({ type: 'bootstrap', seq: seq++, source: 'sbi_live', trading_date: String(app.date).replaceAll('-', ''),
106
- input_transport, source_timestamp_origin: 'brisk_decoder_unverified', exchange_delay_ms: null,
107
- source_time_us: now, market_issue_count: decoder.master.length, master, quotes });
108
- }
109
- const t0 = process.hrtime.bigint();
110
- decoder.feed(frame);
111
- const quotes = decoder.changed().filter(id => ids.has(id)).map(id => decoder.quote(id));
112
- updates += quotes.length;
113
- await emit({ type: 'quotes', seq: seq++, source_time_us: decoder.time(), received_unix_ms,
114
- decode_ns: Number(process.hrtime.bigint() - t0), replay_lateness_ms: null, quotes });
115
- };
116
- socket.addEventListener('message', event => {
117
- if (typeof event.data === 'string') return; // Text control messages carry no market data.
118
- work = work.then(() => failure || handle(event.data)).catch(fail);
119
- });
120
- socket.addEventListener('error', event => fail(new Error(`SBI BRiSK WebSocket error: ${event.message || 'connection failed'}`)));
121
- const [closed] = await once(socket, 'close');
122
- clearTimeout(timer);
123
- await work;
124
- if (failure) throw failure;
125
- if (closed.code !== 1000 || !started) throw new Error(`SBI BRiSK stream closed (${closed.code} ${closed.reason || ''})`.trim());
126
- const summary = { type: 'end', seq, source_time_us: decoder.time(), frames, quote_updates: updates,
127
- replay_wall_ms: performance.now() - start };
128
- await emit(summary);
129
- return summary;
130
- }
131
-
132
- async function main(argv, env = process.env) {
133
- const codesAt = argv.indexOf('--codes');
134
- if (argv.length && (codesAt !== 0 || argv.length !== 2)) throw new Error('Usage: BRISK_SBI_COOKIES=... sbi.cjs [--codes 7203,6758]');
135
- await live({ cookies: JSON.parse(env.BRISK_SBI_COOKIES || 'null'), codes: codesAt === 0 ? argv[1].split(',') : [],
136
- emit: async record => {
137
- if (!process.stdout.write(JSON.stringify(record) + '\n')) await once(process.stdout, 'drain');
138
- } });
139
- }
140
-
141
- module.exports = { Session, decoderAssets, checkAbi, live, main, PROTOCOL_VERSION, REQUIRED };
142
- if (require.main === module) main(process.argv.slice(2)).catch(error => {
143
- console.error(error.message); process.exitCode = 1;
144
- });
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes