briskapi 0.2.2__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.
- {briskapi-0.2.2 → briskapi-0.3.0}/PKG-INFO +13 -7
- {briskapi-0.2.2 → briskapi-0.3.0}/README.md +12 -6
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/__init__.py +1 -1
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/cli.py +3 -1
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/decoder/decoder.cjs +48 -9
- briskapi-0.3.0/briskapi/decoder/engineio.cjs +261 -0
- briskapi-0.3.0/briskapi/decoder/sbi.cjs +287 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/sbi.py +17 -3
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi.egg-info/PKG-INFO +13 -7
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi.egg-info/SOURCES.txt +1 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/pyproject.toml +1 -1
- {briskapi-0.2.2 → briskapi-0.3.0}/tests/test_sbi.py +23 -0
- briskapi-0.2.2/briskapi/decoder/sbi.cjs +0 -144
- {briskapi-0.2.2 → briskapi-0.3.0}/LICENSE +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/LICENSE-pybrisk.txt +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/__main__.py +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/_archive.py +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/_live.py +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/_market.py +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/_recording.py +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/archive.json +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/decoder/assets.json +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/decoder/web.cjs +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/references/historical_mock.json +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/schema.py +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi/timing.py +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi.egg-info/dependency_links.txt +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi.egg-info/entry_points.txt +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi.egg-info/requires.txt +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/briskapi.egg-info/top_level.txt +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/setup.cfg +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/tests/test_api.py +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/tests/test_archive.py +0 -0
- {briskapi-0.2.2 → briskapi-0.3.0}/tests/test_package.py +0 -0
- {briskapi-0.2.2 → 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.
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
@@ -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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
@@ -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.
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
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
|
-
|
|
158
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
@@ -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
|
|
@@ -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
|
|
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
|