briskapi 0.2.0__py3-none-any.whl
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/LICENSE-pybrisk.txt +21 -0
- briskapi/__init__.py +67 -0
- briskapi/__main__.py +3 -0
- briskapi/_archive.py +62 -0
- briskapi/_live.py +376 -0
- briskapi/_market.py +136 -0
- briskapi/_recording.py +192 -0
- briskapi/archive.json +5 -0
- briskapi/cli.py +337 -0
- briskapi/decoder/assets.json +13 -0
- briskapi/decoder/decoder.cjs +282 -0
- briskapi/decoder/sbi.cjs +144 -0
- briskapi/decoder/web.cjs +44 -0
- briskapi/references/historical_mock.json +4140 -0
- briskapi/sbi.py +266 -0
- briskapi/schema.py +351 -0
- briskapi/timing.py +72 -0
- briskapi-0.2.0.dist-info/METADATA +201 -0
- briskapi-0.2.0.dist-info/RECORD +23 -0
- briskapi-0.2.0.dist-info/WHEEL +5 -0
- briskapi-0.2.0.dist-info/entry_points.txt +2 -0
- briskapi-0.2.0.dist-info/licenses/LICENSE +21 -0
- briskapi-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Adapter for the pinned Next demo's public WASM ABI, not a guessed wire decoder.
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const vm = require('node:vm');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const { once } = require('node:events');
|
|
8
|
+
const { setTimeout: delay } = require('node:timers/promises');
|
|
9
|
+
const manifest = require('./assets.json');
|
|
10
|
+
const { verifyAsset, loadWebAssets } = require('./web.cjs');
|
|
11
|
+
const BUFFER = 4 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
function u64(words, i) {
|
|
14
|
+
const value = words[i] + 4294967296 * words[i + 1];
|
|
15
|
+
if (!Number.isSafeInteger(value)) throw new Error('Unsafe 64-bit quantity/timestamp');
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function* frames(data) {
|
|
20
|
+
let previous = -1;
|
|
21
|
+
for (let offset = 0; offset < data.length;) {
|
|
22
|
+
if (offset + 8 > data.length) throw new Error('Truncated mock frame header');
|
|
23
|
+
const time = data.readUInt32LE(offset);
|
|
24
|
+
const size = data.readUInt32LE(offset + 4);
|
|
25
|
+
if (!size || size > BUFFER || offset + 8 + size > data.length) throw new Error('Invalid mock frame length');
|
|
26
|
+
if (time < previous) throw new Error('Mock replay times regressed');
|
|
27
|
+
previous = time;
|
|
28
|
+
yield { time, data: data.subarray(offset + 8, offset + 8 + size) };
|
|
29
|
+
offset += 8 + size;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// QR history is needed by the vendor's rollback/last-price callbacks. Retain it
|
|
34
|
+
// for the finite recording; do not replace it with the trade-only upstream hook.
|
|
35
|
+
class QuoteHistory {
|
|
36
|
+
constructor() { this.qrs = new Map(); }
|
|
37
|
+
initialize(p) { this.qrs.set(p, []); }
|
|
38
|
+
deinitialize(p) { this.qrs.delete(p); }
|
|
39
|
+
clone(dst, src) { this.qrs.set(dst, this.qrs.get(src).slice()); }
|
|
40
|
+
emplace(p, type, timestamp, price10, qty, lot, frame) { this.qrs.get(p).push({ price10, frame }); }
|
|
41
|
+
popBack(p) { this.qrs.get(p).pop(); }
|
|
42
|
+
size(p) { return this.qrs.get(p).length; }
|
|
43
|
+
lastPrice10(p) { return this.qrs.get(p).at(-1)?.price10 || 0; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadAssets(cache) {
|
|
47
|
+
const assets = {};
|
|
48
|
+
for (const name of Object.keys(manifest.assets)) {
|
|
49
|
+
const data = fs.readFileSync(path.join(cache, name));
|
|
50
|
+
assets[name] = verifyAsset(name, data);
|
|
51
|
+
}
|
|
52
|
+
return assets;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class Decoder {
|
|
56
|
+
// options.protocolVersion: 16000 for the Next demo (default), 18000 for SBI BRiSK.
|
|
57
|
+
static async create(assets, options = {}) {
|
|
58
|
+
// Isolate the legacy glue's globals and process exception handlers. No UI,
|
|
59
|
+
// network, filesystem access or account state is needed inside this VM.
|
|
60
|
+
const context = vm.createContext({
|
|
61
|
+
module: { exports: {} }, exports: {}, qrm: new QuoteHistory(),
|
|
62
|
+
console: { log() {}, warn() {}, error() {} },
|
|
63
|
+
crypto: crypto.webcrypto, TextDecoder, TextEncoder, setTimeout, clearTimeout,
|
|
64
|
+
});
|
|
65
|
+
vm.runInContext(assets['fita.js'].toString('utf8'), context, { filename: 'brisk-demo-fita.js' });
|
|
66
|
+
const { wasm } = await new Promise((resolve, reject) => {
|
|
67
|
+
context.module.exports({ wasmBinary: assets['fita.wasm'],
|
|
68
|
+
print() {}, printErr() {}, onAbort: reject,
|
|
69
|
+
}).then(wasm => resolve({ wasm }));
|
|
70
|
+
});
|
|
71
|
+
options.check?.(wasm);
|
|
72
|
+
return new Decoder(wasm, assets, options);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
constructor(w, assets, { protocolVersion = manifest.protocol_version } = {}) {
|
|
76
|
+
this.w = w;
|
|
77
|
+
this.authError = false;
|
|
78
|
+
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);
|
|
83
|
+
this.buf = w._malloc(BUFFER);
|
|
84
|
+
this.aux = w._malloc(64);
|
|
85
|
+
this.push(assets['master.dat']);
|
|
86
|
+
w._applyBasePriceQueue(this.id);
|
|
87
|
+
const count = w._stockCount(this.id);
|
|
88
|
+
if (count <= 0 || count * 144 > BUFFER) throw new Error('Invalid mock master count');
|
|
89
|
+
w._getStockMaster(this.id, this.buf, count * 144);
|
|
90
|
+
this.master = Array.from({ length: count }, (_, issue_id) => {
|
|
91
|
+
const a = new Uint32Array(w.HEAPU8.buffer, this.buf + 144 * issue_id, 36);
|
|
92
|
+
return { issue_id, code: String(a[0]), tick_type: a[1], base_price10: a[2],
|
|
93
|
+
limit_up10: a[3], limit_down10: a[4], lot_size: u64(a, 5), issue_type: a[7],
|
|
94
|
+
name: Buffer.from(w.HEAPU8.subarray(this.buf + 144 * issue_id + 68,
|
|
95
|
+
this.buf + 144 * issue_id + 68 + a[16])).toString('utf8') };
|
|
96
|
+
});
|
|
97
|
+
this.unserialize(assets['snapshot.dat']);
|
|
98
|
+
this.date = String(w._getDate(this.id));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
push(data) {
|
|
102
|
+
for (let offset = 0; offset < data.length; offset += BUFFER) {
|
|
103
|
+
const part = data.subarray(offset, offset + BUFFER);
|
|
104
|
+
this.w.HEAPU8.set(part, this.buf);
|
|
105
|
+
this.w._push(this.id, this.buf, part.length);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
unserialize(data) {
|
|
110
|
+
let start = 0, size = 0, first = 0, count = 0;
|
|
111
|
+
const flush = () => {
|
|
112
|
+
this.w.HEAPU8.set(data.subarray(start, start + size), this.buf);
|
|
113
|
+
this.w._unserialize(this.id, this.buf, size, first, first + count);
|
|
114
|
+
start += size; first += count; size = 0; count = 0;
|
|
115
|
+
};
|
|
116
|
+
for (let offset = 0; offset < data.length;) {
|
|
117
|
+
if (offset + 4 > data.length) throw new Error('Truncated snapshot header');
|
|
118
|
+
const length = data.readUInt32LE(offset);
|
|
119
|
+
if (length < 4 || length > BUFFER || offset + length > data.length) throw new Error('Invalid snapshot record');
|
|
120
|
+
if (size + length > BUFFER) flush();
|
|
121
|
+
size += length; count++; offset += length;
|
|
122
|
+
}
|
|
123
|
+
if (size) flush();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Bars of OHLC history ahead of the quote fields in a stock view. SBI's build
|
|
127
|
+
// reports it once its stream is initialized; the demo's comes from the manifest.
|
|
128
|
+
get ohlc() {
|
|
129
|
+
this._ohlc ??= typeof this.w._ohlcLength === 'function' ? this.w._ohlcLength(this.id) : manifest.ohlc_length;
|
|
130
|
+
return this._ohlc;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
get viewWords() {
|
|
134
|
+
return 37 + 6 * this.ohlc;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
time() {
|
|
138
|
+
this.w._getTime(this.id, this.aux);
|
|
139
|
+
return u64(new Uint32Array(this.w.HEAPU8.buffer, this.aux, 2), 0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
feed(data) {
|
|
143
|
+
if (data.length > BUFFER) throw new Error('Frame exceeds decoder buffer');
|
|
144
|
+
this.w.HEAPU8.set(data, this.buf);
|
|
145
|
+
this.w._pushWs(this.id, this.buf, data.length, this.aux, this.aux + 8);
|
|
146
|
+
this.w._applyBasePriceQueue(this.id);
|
|
147
|
+
if (this.authError) throw new Error('Decoder rejected session');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Feed one frame; true once the decoder has reported its initial frame numbers
|
|
151
|
+
// and quote tracing has started (the demo's first frame always does this).
|
|
152
|
+
start(frame) {
|
|
153
|
+
this.feed(frame);
|
|
154
|
+
if (!this.initialFrames) return false;
|
|
155
|
+
if (this.initialFrames.length !== this.master.length) throw new Error('Missing frame-number bootstrap');
|
|
156
|
+
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');
|
|
159
|
+
this.w._apiRecieved(this.id);
|
|
160
|
+
this.trace = this.w._addTraceUpdate(this.id);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
changed() {
|
|
165
|
+
const count = this.w._getTrace(this.id, this.trace, this.buf, 8192);
|
|
166
|
+
if (count < 0 || count > 8192) throw new Error('Decoder trace overflow');
|
|
167
|
+
const ids = Array.from(new Uint16Array(this.w.HEAPU8.buffer, this.buf, count));
|
|
168
|
+
this.w._clearTrace(this.id, this.trace);
|
|
169
|
+
if (ids.some(id => id >= this.master.length)) throw new Error('Invalid trace issue ID');
|
|
170
|
+
return ids;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
quote(issue_id) {
|
|
174
|
+
const m = this.master[issue_id];
|
|
175
|
+
if (!m) throw new Error(`Unknown issue ID: ${issue_id}`);
|
|
176
|
+
if (!this.w._getStockView(this.id, issue_id, this.buf)) throw new Error(`Stock view unavailable: ${m.code}`);
|
|
177
|
+
const words = new Uint32Array(this.w.HEAPU8.buffer, this.buf, this.viewWords);
|
|
178
|
+
const a = words.subarray(6 * this.ohlc);
|
|
179
|
+
const quote = { issue_id, code: m.code, frame: a[19], max_frame: a[20],
|
|
180
|
+
source_time_us: u64(a, 17), last_price10: words[1], open_price10: words[2],
|
|
181
|
+
bid_price10: a[7], ask_price10: a[8], indicative_price10: a[9],
|
|
182
|
+
indicative_volume: u64(a, 10), indicative_side: a[12],
|
|
183
|
+
closing_indicative_price10: a[13], closing_indicative_volume: u64(a, 14),
|
|
184
|
+
quote_flag: a[21], quote_side: a[22], special_quote_time_us: u64(a, 23),
|
|
185
|
+
indicative_open_price10: a[32], auction_reference_price10: a[35],
|
|
186
|
+
volume: u64(a, 3) };
|
|
187
|
+
// SBI's build has no portfolio export, so its quotes carry no issue_status.
|
|
188
|
+
if (this.w._getPortfolio) {
|
|
189
|
+
if (!this.w._getPortfolio(this.id, Number(m.code), this.buf)) throw new Error(`Portfolio unavailable: ${m.code}`);
|
|
190
|
+
quote.issue_status = new Uint32Array(this.w.HEAPU8.buffer, this.buf, 22 + this.ohlc)[this.ohlc + 15];
|
|
191
|
+
}
|
|
192
|
+
// Request one price row solely to obtain the market/over/under rows in the
|
|
193
|
+
// documented ABI. Row 0 is market orders, not the top-of-book depth.
|
|
194
|
+
const price = quote.indicative_price10 || quote.bid_price10 || quote.ask_price10 || m.base_price10;
|
|
195
|
+
const index = this.w._fitItaViewRowPrice10(this.id, issue_id, price, 1);
|
|
196
|
+
if (!this.w._getItaRows(this.id, issue_id, index, 1, this.buf + 612,
|
|
197
|
+
this.buf, this.buf + 204, this.buf + 408, 0)) throw new Error(`Book unavailable: ${m.code}`);
|
|
198
|
+
const row = new Uint32Array(this.w.HEAPU8.buffer, this.buf, 51);
|
|
199
|
+
quote.market_buy_quantity = u64(row, 5);
|
|
200
|
+
quote.market_sell_quantity = u64(row, 28);
|
|
201
|
+
quote.closing_market_buy_quantity = u64(row, 16);
|
|
202
|
+
quote.closing_market_sell_quantity = u64(row, 39);
|
|
203
|
+
return quote;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function replay({ cache, web = false, codes = [], speed = 1, limitFrames = Infinity, emit }) {
|
|
208
|
+
if (!Number.isFinite(speed) || speed < 0) throw new Error('Speed must be >= 0 (0 means unpaced)');
|
|
209
|
+
if (!(limitFrames > 0)) throw new Error('Frame limit must be positive');
|
|
210
|
+
if (Boolean(cache) === Boolean(web)) throw new Error('Select exactly one of --cache DIR or --web');
|
|
211
|
+
const { assets, input_transport } = web ? await loadWebAssets()
|
|
212
|
+
: { assets: loadAssets(cache), input_transport: { kind: 'local_cache' } };
|
|
213
|
+
const decoder = await Decoder.create(assets);
|
|
214
|
+
if (decoder.date !== manifest.trading_date.replaceAll('-', '')) throw new Error('Unexpected mock trading date');
|
|
215
|
+
const selected = new Set(codes);
|
|
216
|
+
const master = decoder.master.filter(m => !selected.size || selected.has(m.code));
|
|
217
|
+
const missing = codes.filter(code => !master.some(m => m.code === code));
|
|
218
|
+
if (missing.length) throw new Error(`Codes missing from 2021 mock: ${missing.join(',')}`);
|
|
219
|
+
const ids = new Set(master.map(m => m.issue_id));
|
|
220
|
+
const iter = frames(assets['ws.dat']);
|
|
221
|
+
const first = iter.next().value;
|
|
222
|
+
if (!first) throw new Error('Empty mock recording');
|
|
223
|
+
if (!decoder.start(first.data)) throw new Error('Missing frame-number bootstrap');
|
|
224
|
+
let seq = 0;
|
|
225
|
+
await emit({ type: 'bootstrap', seq: seq++, source: 'historical_mock', trading_date: decoder.date, input_transport,
|
|
226
|
+
source_timestamp_origin: 'brisk_decoder_unverified', exchange_delay_ms: null,
|
|
227
|
+
source_time_us: decoder.time(), market_issue_count: decoder.master.length, master,
|
|
228
|
+
quotes: master.map(m => decoder.quote(m.issue_id)) });
|
|
229
|
+
const start = performance.now();
|
|
230
|
+
let frameCount = 1, updateCount = 0;
|
|
231
|
+
for (const frame of iter) {
|
|
232
|
+
if (frameCount >= limitFrames) break;
|
|
233
|
+
if (speed) {
|
|
234
|
+
const wait = (frame.time - first.time) / speed - (performance.now() - start);
|
|
235
|
+
if (wait > 0) await delay(wait);
|
|
236
|
+
}
|
|
237
|
+
const received_unix_ms = Date.now();
|
|
238
|
+
// Compare local monotonic elapsed time with the replay schedule. The
|
|
239
|
+
// recording clock is historical and cannot measure exchange delay.
|
|
240
|
+
const replay_lateness_ms = speed ? Math.max(0,
|
|
241
|
+
performance.now() - start - (frame.time - first.time) / speed) : null;
|
|
242
|
+
const began = process.hrtime.bigint();
|
|
243
|
+
decoder.feed(frame.data);
|
|
244
|
+
const quotes = decoder.changed().filter(id => ids.has(id)).map(id => decoder.quote(id));
|
|
245
|
+
const decode_ns = Number(process.hrtime.bigint() - began);
|
|
246
|
+
// Include empty batches: sequence continuity and feed health differ from a
|
|
247
|
+
// quiet security's last update. No fixed polling interval or dropped deltas.
|
|
248
|
+
await emit({ type: 'quotes', seq: seq++, source_time_us: decoder.time(),
|
|
249
|
+
received_unix_ms, decode_ns, replay_lateness_ms, quotes });
|
|
250
|
+
updateCount += quotes.length; frameCount++;
|
|
251
|
+
}
|
|
252
|
+
const summary = { type: 'end', seq, source_time_us: decoder.time(), frames: frameCount,
|
|
253
|
+
quote_updates: updateCount, replay_wall_ms: performance.now() - start };
|
|
254
|
+
await emit(summary);
|
|
255
|
+
return summary;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function main(argv) {
|
|
259
|
+
const args = {};
|
|
260
|
+
for (let i = 0; i < argv.length;) {
|
|
261
|
+
if (argv[i] === '--web') {
|
|
262
|
+
if (args['--web']) throw new Error('Duplicate --web');
|
|
263
|
+
args['--web'] = true; i++; continue;
|
|
264
|
+
}
|
|
265
|
+
if (!['--cache', '--codes', '--speed', '--limit-frames'].includes(argv[i]) || argv[i + 1] === undefined || argv[i + 1].startsWith('--')) {
|
|
266
|
+
throw new Error('Usage: decoder.cjs (--cache DIR | --web) [--codes 7203,6758] [--speed 1] [--limit-frames N]');
|
|
267
|
+
}
|
|
268
|
+
if (args[argv[i]] !== undefined) throw new Error(`Duplicate ${argv[i]}`);
|
|
269
|
+
args[argv[i]] = argv[i + 1]; i += 2;
|
|
270
|
+
}
|
|
271
|
+
if (Boolean(args['--cache']) === Boolean(args['--web'])) throw new Error('Select exactly one of --cache DIR or --web');
|
|
272
|
+
await replay({ cache: args['--cache'], web: args['--web'] || false, codes: args['--codes']?.split(',') || [],
|
|
273
|
+
speed: Number(args['--speed'] ?? 1), limitFrames: args['--limit-frames'] ? Number(args['--limit-frames']) : Infinity,
|
|
274
|
+
emit: async record => {
|
|
275
|
+
if (!process.stdout.write(JSON.stringify(record) + '\n')) await once(process.stdout, 'drain');
|
|
276
|
+
} });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
module.exports = { Decoder, QuoteHistory, frames, u64, loadAssets, replay, main };
|
|
280
|
+
if (require.main === module) main(process.argv.slice(2)).catch(error => {
|
|
281
|
+
console.error(error.message); process.exitCode = 1;
|
|
282
|
+
});
|
briskapi/decoder/sbi.cjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
});
|
briskapi/decoder/web.cjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The public Next demo serves recorded assets over HTTPS, not a live WebSocket.
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const manifest = require('./assets.json');
|
|
5
|
+
const MAX_ASSET_BYTES = 32 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
function verifyAsset(name, data) {
|
|
8
|
+
if (crypto.createHash('sha256').update(data).digest('hex') !== manifest.assets[name].sha256) {
|
|
9
|
+
throw new Error(`Asset hash mismatch: ${name}`);
|
|
10
|
+
}
|
|
11
|
+
return data;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function fetchAsset(name, { fetchImpl = fetch, timeoutMs = 30000 } = {}) {
|
|
15
|
+
const response = await fetchImpl(manifest.assets[name].url, {
|
|
16
|
+
signal: AbortSignal.timeout(timeoutMs), redirect: 'error',
|
|
17
|
+
});
|
|
18
|
+
if (!response.ok) throw new Error(`Demo asset ${name}: HTTP ${response.status}`);
|
|
19
|
+
if (!response.body) throw new Error(`Demo asset ${name}: empty response body`);
|
|
20
|
+
const chunks = [];
|
|
21
|
+
let size = 0;
|
|
22
|
+
for await (const chunk of response.body) {
|
|
23
|
+
size += chunk.length;
|
|
24
|
+
if (size > MAX_ASSET_BYTES) throw new Error(`Demo asset ${name}: exceeds size limit`);
|
|
25
|
+
chunks.push(Buffer.from(chunk));
|
|
26
|
+
}
|
|
27
|
+
return verifyAsset(name, Buffer.concat(chunks, size));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function loadWebAssets(options) {
|
|
31
|
+
const began = performance.now();
|
|
32
|
+
// Wait for every request to settle, including on failure, before reporting it.
|
|
33
|
+
const names = Object.keys(manifest.assets);
|
|
34
|
+
const results = await Promise.allSettled(names.map(name => fetchAsset(name, options)));
|
|
35
|
+
const failed = results.find(result => result.status === 'rejected');
|
|
36
|
+
if (failed) throw failed.reason;
|
|
37
|
+
return {
|
|
38
|
+
assets: Object.fromEntries(names.map((name, i) => [name, results[i].value])),
|
|
39
|
+
input_transport: { kind: 'https_recorded_assets', origin: manifest.source,
|
|
40
|
+
asset_fetch_ms: performance.now() - began },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { verifyAsset, fetchAsset, loadWebAssets, MAX_ASSET_BYTES };
|