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
briskapi/sbi.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""SBI BRiSK (sbi.brisk.jp) session: login, REST data and an experimental live feed.
|
|
2
|
+
|
|
3
|
+
The data is used through briskapi's own Ticker and Market:
|
|
4
|
+
|
|
5
|
+
import briskapi
|
|
6
|
+
from briskapi import sbi
|
|
7
|
+
|
|
8
|
+
sbi.login(cookies={"session_bfaf77a2": "v2.local..."}) # from your logged-in browser
|
|
9
|
+
briskapi.Ticker("7203").candles("5m").to_pandas()
|
|
10
|
+
briskapi.Market().events()
|
|
11
|
+
feed = sbi.connect(codes=["7203"]) # live, via Node (experimental)
|
|
12
|
+
|
|
13
|
+
The endpoint sequence (cookie login, token boot, data endpoints) was learned from
|
|
14
|
+
pybrisk (https://github.com/obichan117/pybrisk), Copyright (c) 2026 obichan117,
|
|
15
|
+
MIT License; see LICENSE-pybrisk.txt in this package.
|
|
16
|
+
|
|
17
|
+
Your session cookies are credentials. They are sent only to sbi.brisk.jp and are
|
|
18
|
+
kept in memory unless you pass remember=True. SBI data is never contributed to
|
|
19
|
+
the shared archive.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import datetime as dt
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
import time
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.parse
|
|
31
|
+
import urllib.request
|
|
32
|
+
import zlib
|
|
33
|
+
|
|
34
|
+
from briskapi._recording import JST, BriskError, NotFoundError, Table
|
|
35
|
+
|
|
36
|
+
ORIGIN = 'https://sbi.brisk.jp'
|
|
37
|
+
DECODER = Path(__file__).resolve().parent / 'decoder' / 'sbi.cjs'
|
|
38
|
+
INTERVALS = ('5m', '1d', '1w', '1mo')
|
|
39
|
+
SCHEDULE = {'morning_pre_open': 'morning_session_pre_open_time', 'morning_open': 'morning_session_open_time',
|
|
40
|
+
'morning_close': 'morning_session_close_time', 'afternoon_pre_open': 'afternoon_session_pre_open_time',
|
|
41
|
+
'afternoon_open': 'afternoon_session_open_time', 'afternoon_pre_close': 'afternoon_session_pre_close_time',
|
|
42
|
+
'afternoon_close': 'afternoon_session_close_time'}
|
|
43
|
+
MARGIN = {'long_balance': 'kakuhoLongShares', 'short_balance': 'kakuhoShortShares',
|
|
44
|
+
'preliminary_long': 'sokuhoLongShares', 'preliminary_short': 'sokuhoShortShares',
|
|
45
|
+
'standardized_long': 'standardizedLongShares', 'standardized_short': 'standardizedShortShares',
|
|
46
|
+
'lending_fee': 'gyakuhibuFee', 'lending_fee_pct': 'gyakuhibuFeePercent',
|
|
47
|
+
'lending_fee_days': 'gyakuhibuFeeDayCount', 'lending_fee_max': 'gyakuhibuMaxFee'}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SessionExpiredError(BriskError):
|
|
51
|
+
"""The SBI BRiSK session is missing, invalid or expired. Log in again."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class APIError(BriskError):
|
|
55
|
+
"""Unexpected HTTP status from SBI BRiSK."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, status, message=''):
|
|
58
|
+
self.status = status
|
|
59
|
+
super().__init__(f'HTTP {status}: {message}' if message else f'HTTP {status}')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RateLimitError(APIError):
|
|
63
|
+
"""Too many requests (429)."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
67
|
+
# A redirect means the session went to a login page. Following it would also
|
|
68
|
+
# forward cookies and the bearer token to wherever it points.
|
|
69
|
+
def redirect_request(self, *args):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cookies_path() -> Path:
|
|
74
|
+
return Path(os.environ.get('XDG_CONFIG_HOME') or Path.home() / '.config') / 'brisk' / 'sbi-cookies.json'
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Session:
|
|
78
|
+
"""Cookie-authenticated HTTP to sbi.brisk.jp: rate limited, no redirects."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, cookies, rate_limit=1.0, timeout=30, opener=None):
|
|
81
|
+
if not cookies:
|
|
82
|
+
raise SessionExpiredError('No SBI BRiSK cookies: copy them from your logged-in browser and call sbi.login()')
|
|
83
|
+
self.cookies = dict(cookies)
|
|
84
|
+
self.rate_limit, self.timeout = rate_limit, timeout
|
|
85
|
+
self.token = None
|
|
86
|
+
self._opener = opener or urllib.request.build_opener(_NoRedirect)
|
|
87
|
+
self._last = 0.0
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def cookie_header(self) -> str:
|
|
91
|
+
return '; '.join(f'{k}={v}' for k, v in self.cookies.items())
|
|
92
|
+
|
|
93
|
+
def get(self, path, params=None, raw=False):
|
|
94
|
+
if self.rate_limit > 0:
|
|
95
|
+
wait = 1 / self.rate_limit - (time.monotonic() - self._last)
|
|
96
|
+
if wait > 0:
|
|
97
|
+
time.sleep(wait)
|
|
98
|
+
self._last = time.monotonic()
|
|
99
|
+
url = ORIGIN + path + ('?' + urllib.parse.urlencode(params) if params else '')
|
|
100
|
+
headers = {'Cookie': self.cookie_header, 'Accept': 'application/json'}
|
|
101
|
+
if self.token:
|
|
102
|
+
headers['Authorization'] = f'Bearer {self.token}'
|
|
103
|
+
try:
|
|
104
|
+
with self._opener.open(urllib.request.Request(url, headers=headers), timeout=self.timeout) as response:
|
|
105
|
+
body = response.read()
|
|
106
|
+
except urllib.error.HTTPError as e:
|
|
107
|
+
text = e.read(500).decode(errors='replace')
|
|
108
|
+
if e.code in (301, 302, 303, 307, 308, 401, 403):
|
|
109
|
+
raise SessionExpiredError('SBI BRiSK session expired or invalid; log in again') from None
|
|
110
|
+
if e.code == 404:
|
|
111
|
+
raise NotFoundError(f'{path}: {text}') from None
|
|
112
|
+
if e.code == 429:
|
|
113
|
+
raise RateLimitError(429, text) from None
|
|
114
|
+
raise APIError(e.code, text) from None
|
|
115
|
+
return body if raw else json.loads(body)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Client:
|
|
119
|
+
"""One SBI BRiSK session. Booting exchanges cookies for an API token on first use."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, cookies=None, session=None):
|
|
122
|
+
self.session = session or Session(cookies)
|
|
123
|
+
self._boot = None
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def boot(self) -> dict:
|
|
127
|
+
"""The app boot document: trading date, series, schedule, WebSocket URL, master/snapshot hashes."""
|
|
128
|
+
if self._boot is None:
|
|
129
|
+
self.session.token = self.session.get('/api/frontend/boot')['api_token']
|
|
130
|
+
self._boot = self.session.get('/api/app/boot')
|
|
131
|
+
return self._boot
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def date(self) -> str:
|
|
135
|
+
return self.boot['date']
|
|
136
|
+
|
|
137
|
+
def candles(self, code, interval='1d') -> Table:
|
|
138
|
+
"""Price bars: 5m (today, numbered), 1d, 1w or 1mo."""
|
|
139
|
+
if interval not in INTERVALS:
|
|
140
|
+
raise ValueError(f"Invalid interval: {interval!r}. Use '5m', '1d', '1w', or '1mo'.")
|
|
141
|
+
data = self.session.get(f'/api/ohlc/{urllib.parse.quote(str(code))}', {'date': self.date})
|
|
142
|
+
key, period = {'5m': ('ohlc5min', {'date': 'date', 'bar': 'index'}), '1d': ('ohlc1day', {'date': 'date'}),
|
|
143
|
+
'1w': ('ohlc1week', {'year': 'year', 'week': 'week'}),
|
|
144
|
+
'1mo': ('ohlc1month', {'year': 'year', 'month': 'month'})}[interval]
|
|
145
|
+
return Table({**{name: bar[src] for name, src in period.items()}, 'open': bar['open_price'],
|
|
146
|
+
'high': bar['high_price'], 'low': bar['low_price'], 'close': bar['close_price'],
|
|
147
|
+
'turnover': bar['turnover']} for bar in data.get(key, []))
|
|
148
|
+
|
|
149
|
+
def margin(self, code, days=365) -> Table:
|
|
150
|
+
"""Margin balances and stock-lending fees (JSFC), one row per trading day."""
|
|
151
|
+
data = self.session.get(f'/api/jsfc/{urllib.parse.quote(str(code))}', {'count': days})
|
|
152
|
+
return Table({'date': e['date'], **{name: e.get(src) for name, src in MARGIN.items()}} for e in data.values())
|
|
153
|
+
|
|
154
|
+
def turnover(self) -> Table:
|
|
155
|
+
"""Turnover and shares outstanding for every listed stock."""
|
|
156
|
+
return Table({'code': i['issue_code'], 'turnover': i.get('turnover'),
|
|
157
|
+
'shares_outstanding': i.get('calc_shares_outstanding')}
|
|
158
|
+
for i in self.session.get('/api/stocks_info', {'date': self.date}))
|
|
159
|
+
|
|
160
|
+
def lists(self) -> dict[str, list[str]]:
|
|
161
|
+
"""Curated stock lists (NK225, recent IPOs, …) by list ID."""
|
|
162
|
+
data = self.session.get('/api/stock_lists', {'date': self.date})
|
|
163
|
+
return {entry['id']: entry['issue_codes'] for entry in data['stock_lists']}
|
|
164
|
+
|
|
165
|
+
def events(self, first=0, last=618) -> Table:
|
|
166
|
+
"""Market events: basket orders, limit up/down, volume surges and so on."""
|
|
167
|
+
data = self.session.get('/api/markets', {'date': self.date, 'series': self.boot['series'],
|
|
168
|
+
'index_from': first, 'index_to': last})
|
|
169
|
+
return Table({'index': c['index'], 'code': c.get('issue_code', ''), 'kind': c.get('kind'),
|
|
170
|
+
'type': c.get('type', 0), 'price': _yen(c.get('price10')), 'value': _yen(c.get('value10')),
|
|
171
|
+
'change_bps': c.get('diff_bps_from_last'), 'time': _at(self.date, c.get('time', ''))}
|
|
172
|
+
for c in data['market_conditions'])
|
|
173
|
+
|
|
174
|
+
def schedule(self) -> dict:
|
|
175
|
+
"""Trading date, session status and session times (JST datetimes)."""
|
|
176
|
+
boot = self.boot
|
|
177
|
+
info = boot['schedule_info']
|
|
178
|
+
return {'date': boot['date'], 'status': boot['session_status'],
|
|
179
|
+
**{name: _at(boot['date'], info[key]) for name, key in SCHEDULE.items()}}
|
|
180
|
+
|
|
181
|
+
def watchlist(self) -> list[str]:
|
|
182
|
+
"""Codes saved in your BRiSK watchlist."""
|
|
183
|
+
data = self.session.get('/api/frontend/watchlist')
|
|
184
|
+
if data.get('empty'):
|
|
185
|
+
return []
|
|
186
|
+
content = json.loads(zlib.decompress(base64.b64decode(data['data'])))
|
|
187
|
+
if isinstance(content, dict):
|
|
188
|
+
return [item['code'] for group in content.get('groups', []) for item in group.get('items', []) if 'code' in item]
|
|
189
|
+
return [item if isinstance(item, str) else item['code'] for item in content
|
|
190
|
+
if isinstance(item, str) or (isinstance(item, dict) and 'code' in item)]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _yen(value10):
|
|
194
|
+
return None if value10 is None else value10 / 10
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _at(date, clock):
|
|
198
|
+
"""JST datetime for 'HH:MM:SS[.ffffff]' or microseconds since midnight on a YYYY-MM-DD date."""
|
|
199
|
+
day = dt.datetime.strptime(date, '%Y-%m-%d').replace(tzinfo=JST)
|
|
200
|
+
if isinstance(clock, int):
|
|
201
|
+
return day + dt.timedelta(microseconds=clock)
|
|
202
|
+
if not clock:
|
|
203
|
+
return None
|
|
204
|
+
h, m, s = clock.split(':')
|
|
205
|
+
return day + dt.timedelta(hours=int(h), minutes=int(m), seconds=float(s))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
_client: Client | None = None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def login(cookies=None, remember=False) -> Client:
|
|
212
|
+
"""Use SBI BRiSK session cookies (from DevTools or pycookiecheat).
|
|
213
|
+
|
|
214
|
+
Without cookies, uses BRISK_SBI_COOKIES (JSON) or cookies saved with remember=True.
|
|
215
|
+
"""
|
|
216
|
+
global _client
|
|
217
|
+
path = cookies_path()
|
|
218
|
+
if cookies is None and os.environ.get('BRISK_SBI_COOKIES'):
|
|
219
|
+
cookies = json.loads(os.environ['BRISK_SBI_COOKIES'])
|
|
220
|
+
if cookies is None and path.exists():
|
|
221
|
+
cookies = json.loads(path.read_text())
|
|
222
|
+
_client = Client(cookies)
|
|
223
|
+
if remember:
|
|
224
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
225
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
226
|
+
with os.fdopen(fd, 'w') as f:
|
|
227
|
+
json.dump(_client.session.cookies, f)
|
|
228
|
+
return _client
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def logout():
|
|
232
|
+
"""Forget the session, including saved cookies."""
|
|
233
|
+
global _client
|
|
234
|
+
_client = None
|
|
235
|
+
cookies_path().unlink(missing_ok=True)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _default() -> Client:
|
|
239
|
+
if _client is None:
|
|
240
|
+
raise SessionExpiredError('SBI BRiSK data needs a session: call briskapi.sbi.login(cookies={...}) first')
|
|
241
|
+
return _client
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def connect(codes=None, history=False, node='node', timeout=120, contribute=None):
|
|
245
|
+
"""Experimental live SBI BRiSK feed: SBI's own WASM decoder under Node, never Chrome.
|
|
246
|
+
|
|
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.
|
|
249
|
+
Market data never leaves your computer. With sharing on (briskapi.consent), a
|
|
250
|
+
timing-only summary is contributed when the session ends; contribute=False
|
|
251
|
+
keeps even that local.
|
|
252
|
+
"""
|
|
253
|
+
from briskapi import load
|
|
254
|
+
from briskapi._live import Feed
|
|
255
|
+
session = _default().session
|
|
256
|
+
command = [node, str(DECODER)]
|
|
257
|
+
if codes:
|
|
258
|
+
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).
|
|
260
|
+
env = {**os.environ, 'BRISK_SBI_COOKIES': json.dumps(session.cookies)}
|
|
261
|
+
feed = Feed(command=command, env=env, history=history, contribute=contribute, timing='sbi_live')
|
|
262
|
+
try:
|
|
263
|
+
return load(feed.ready(timeout))
|
|
264
|
+
except BaseException:
|
|
265
|
+
feed.close()
|
|
266
|
+
raise
|
briskapi/schema.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Bounded, streaming validation shared by local packaging and automatic ingest.
|
|
2
|
+
|
|
3
|
+
The archive accepts only byte-canonical recordings whose market content matches a
|
|
4
|
+
pinned reference replay. A modified client can choose a public alias, a data
|
|
5
|
+
license and bounded local timing measurements; it cannot place other content in
|
|
6
|
+
published objects. The service also recompresses accepted content itself.
|
|
7
|
+
"""
|
|
8
|
+
import datetime as dt
|
|
9
|
+
import gzip
|
|
10
|
+
import hashlib
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
MAX_COMPRESSED = 64 * 1024 * 1024
|
|
18
|
+
MAX_EXPANDED = 1024 * 1024 * 1024
|
|
19
|
+
MAX_LINE = 16 * 1024 * 1024
|
|
20
|
+
SCHEMA = 'brisk-decoded-jsonl-v1'
|
|
21
|
+
SOURCES = {'historical_mock', 'synthetic_test'}
|
|
22
|
+
QUOTE_KEYS = set('issue_id code frame source_time_us max_frame last_price10 open_price10 bid_price10 ask_price10 indicative_price10 indicative_volume indicative_side closing_indicative_price10 closing_indicative_volume quote_flag quote_side special_quote_time_us indicative_open_price10 auction_reference_price10 volume issue_status market_buy_quantity market_sell_quantity closing_market_buy_quantity closing_market_sell_quantity'.split())
|
|
23
|
+
MASTER_KEYS = set('issue_id code tick_type base_price10 limit_up10 limit_down10 lot_size issue_type name'.split())
|
|
24
|
+
BATCH_KEYS = {
|
|
25
|
+
'bootstrap': set('type seq source trading_date input_transport source_timestamp_origin exchange_delay_ms source_time_us market_issue_count master quotes'.split()),
|
|
26
|
+
'quotes': set('type seq source_time_us received_unix_ms decode_ns replay_lateness_ms quotes'.split()),
|
|
27
|
+
'end': set('type seq source_time_us frames quote_updates replay_wall_ms'.split()),
|
|
28
|
+
}
|
|
29
|
+
SUMMARY_KEYS = {'source', 'trading_date', 'first_source_time_us', 'last_source_time_us', 'codes', 'batches', 'quote_updates', 'expanded_bytes'}
|
|
30
|
+
DEMO_ORIGIN = 'https://next-demo.brisk.jp/'
|
|
31
|
+
REFERENCE_DIR = Path(__file__).resolve().parent / 'references'
|
|
32
|
+
# Local measurements are client reported. Plausible ranges and microsecond
|
|
33
|
+
# precision keep them useful as timing data and useless as a payload carrier.
|
|
34
|
+
MAX_DECODE_NS = 10 * 10**9
|
|
35
|
+
UNIX_MS = (1_600_000_000_000, 4_102_444_800_000)
|
|
36
|
+
MAX_SESSION_MS = 86_400_000
|
|
37
|
+
MAX_FETCH_MS = 600_000
|
|
38
|
+
GZIP_LEVEL = 6
|
|
39
|
+
|
|
40
|
+
def require(condition, message):
|
|
41
|
+
if not condition:
|
|
42
|
+
raise ValueError(message)
|
|
43
|
+
|
|
44
|
+
def integer(value, maximum=2**53-1):
|
|
45
|
+
require(type(value) is int and 0 <= value <= maximum, 'Invalid unsigned integer')
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
def measurement(value, maximum):
|
|
49
|
+
"""Non-negative milliseconds with at most microsecond precision."""
|
|
50
|
+
require(type(value) in (int, float) and 0 <= value <= maximum and math.copysign(1, value) > 0
|
|
51
|
+
and round(value, 3) == value, 'Invalid local timing')
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
def code(value):
|
|
55
|
+
require(isinstance(value, str) and re.fullmatch(r'[A-Z0-9]{4,8}', value), 'Invalid security code')
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
def _nonfinite(_):
|
|
59
|
+
raise ValueError('Nonfinite JSON')
|
|
60
|
+
|
|
61
|
+
def canonical(value):
|
|
62
|
+
return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False, allow_nan=False)
|
|
63
|
+
|
|
64
|
+
def encode(batch):
|
|
65
|
+
return (canonical(batch) + '\n').encode()
|
|
66
|
+
|
|
67
|
+
def _issue(item):
|
|
68
|
+
require(isinstance(item, dict) and type(item.get('issue_id')) is int, 'Invalid issue entry')
|
|
69
|
+
return item['issue_id']
|
|
70
|
+
|
|
71
|
+
def canonical_lines(source):
|
|
72
|
+
"""Rewrite decoder JSONL as archive bytes: sorted keys and issues, microsecond timings."""
|
|
73
|
+
while line := source.readline(MAX_LINE + 1):
|
|
74
|
+
require(len(line) <= MAX_LINE, 'Recording exceeds decode limit')
|
|
75
|
+
b = json.loads(line, parse_constant=_nonfinite)
|
|
76
|
+
require(isinstance(b, dict), 'Invalid batch')
|
|
77
|
+
for key in ('master', 'quotes'):
|
|
78
|
+
if isinstance(b.get(key), list):
|
|
79
|
+
b[key].sort(key=_issue)
|
|
80
|
+
for holder, key in ((b, 'replay_lateness_ms'), (b, 'replay_wall_ms'), (b.get('input_transport'), 'asset_fetch_ms')):
|
|
81
|
+
if isinstance(holder, dict) and type(holder.get(key)) is float:
|
|
82
|
+
holder[key] = round(holder[key], 3)
|
|
83
|
+
yield encode(b)
|
|
84
|
+
|
|
85
|
+
def compress(lines, target):
|
|
86
|
+
"""Deterministic gzip (no name or mtime) used for packages and publications."""
|
|
87
|
+
with target.open('wb') as raw, gzip.GzipFile(fileobj=raw, mode='wb', filename='', mtime=0, compresslevel=GZIP_LEVEL) as out:
|
|
88
|
+
for line in lines:
|
|
89
|
+
out.write(line)
|
|
90
|
+
|
|
91
|
+
class Fingerprint:
|
|
92
|
+
"""Digest of market content: the batch clock timeline plus one hash chain per issue."""
|
|
93
|
+
def __init__(self):
|
|
94
|
+
self.timeline = hashlib.sha256()
|
|
95
|
+
self.issues = {}
|
|
96
|
+
|
|
97
|
+
def batch(self, seq, now):
|
|
98
|
+
self.timeline.update(b'%d:%d\n' % (seq, now))
|
|
99
|
+
|
|
100
|
+
def master(self, entry):
|
|
101
|
+
self.issues[entry['issue_id']] = hashlib.sha256(canonical(entry).encode())
|
|
102
|
+
|
|
103
|
+
def quote(self, seq, quote):
|
|
104
|
+
self.issues[quote['issue_id']].update(b'%d:' % seq + canonical(quote).encode() + b'\n')
|
|
105
|
+
|
|
106
|
+
def digest(self, issue):
|
|
107
|
+
return self.issues[issue].hexdigest()[:32]
|
|
108
|
+
|
|
109
|
+
def _transport(value):
|
|
110
|
+
require(value == {'kind': 'local_cache'} or (isinstance(value, dict) and set(value) == {'kind', 'origin', 'asset_fetch_ms'}
|
|
111
|
+
and value['kind'] == 'https_recorded_assets' and value['origin'] == DEMO_ORIGIN), 'Invalid transport fields')
|
|
112
|
+
if 'asset_fetch_ms' in value:
|
|
113
|
+
measurement(value['asset_fetch_ms'], MAX_FETCH_MS)
|
|
114
|
+
|
|
115
|
+
def _scan(stream):
|
|
116
|
+
"""Check structure, canonical bytes and local timing; fingerprint market content."""
|
|
117
|
+
identities, clocks = {}, {}
|
|
118
|
+
fingerprint = Fingerprint()
|
|
119
|
+
seq = last = count = expanded = updates = market = 0
|
|
120
|
+
first = source = date = paced = received = previous_stamp = None
|
|
121
|
+
ended = False
|
|
122
|
+
while True:
|
|
123
|
+
line = stream.readline(MAX_LINE + 1)
|
|
124
|
+
if not line:
|
|
125
|
+
break
|
|
126
|
+
expanded += len(line)
|
|
127
|
+
require(len(line) <= MAX_LINE and expanded <= MAX_EXPANDED, 'Recording exceeds decode limit')
|
|
128
|
+
require(not ended, 'Data after end batch')
|
|
129
|
+
b = json.loads(line, parse_constant=_nonfinite)
|
|
130
|
+
# Exactly one byte encoding per value: no whitespace, duplicate keys,
|
|
131
|
+
# escapes, key order or number spellings that could carry other data.
|
|
132
|
+
require(isinstance(b, dict) and encode(b) == line, 'Non-canonical encoding')
|
|
133
|
+
kind = b.get('type')
|
|
134
|
+
require(kind in BATCH_KEYS and set(b) == BATCH_KEYS[kind], 'Unknown or missing batch fields')
|
|
135
|
+
require(integer(b['seq']) == seq, 'Sequence gap')
|
|
136
|
+
now = integer(b['source_time_us'], 86_400_000_000 - 1)
|
|
137
|
+
require(now >= last, 'Market clock regression')
|
|
138
|
+
fingerprint.batch(seq, now)
|
|
139
|
+
if seq == 0:
|
|
140
|
+
require(kind == 'bootstrap' and b['source'] in SOURCES, 'Unsupported bootstrap/source')
|
|
141
|
+
source, date, first = b['source'], b['trading_date'], now
|
|
142
|
+
require(isinstance(date, str) and re.fullmatch(r'\d{8}', date), 'Invalid date')
|
|
143
|
+
dt.datetime.strptime(date, '%Y%m%d')
|
|
144
|
+
master = b['master']
|
|
145
|
+
require(isinstance(master, list) and 0 < len(master) <= 20000, 'Invalid master size')
|
|
146
|
+
market = integer(b['market_issue_count'])
|
|
147
|
+
require(market >= len(master), 'Invalid market count')
|
|
148
|
+
issue = -1
|
|
149
|
+
for m in master:
|
|
150
|
+
require(isinstance(m, dict) and set(m) == MASTER_KEYS, 'Unknown or missing master fields')
|
|
151
|
+
previous_issue, issue = issue, integer(m['issue_id'], 2**32-1)
|
|
152
|
+
require(issue > previous_issue, 'Master identities must be unique and sorted')
|
|
153
|
+
identities[issue] = code(m['code'])
|
|
154
|
+
for key, value in m.items():
|
|
155
|
+
if key == 'name':
|
|
156
|
+
require(isinstance(value, str) and len(value) <= 256, 'Invalid security name')
|
|
157
|
+
elif key != 'code':
|
|
158
|
+
integer(value)
|
|
159
|
+
fingerprint.master(m)
|
|
160
|
+
# No account/session information is retained in the archive.
|
|
161
|
+
_transport(b['input_transport'])
|
|
162
|
+
require(b['source_timestamp_origin'] == 'brisk_decoder_unverified', 'Invalid clock provenance')
|
|
163
|
+
require(b['exchange_delay_ms'] is None, 'Unverified exchange delay')
|
|
164
|
+
else:
|
|
165
|
+
require(kind in {'quotes', 'end'}, 'Expected delta/end')
|
|
166
|
+
if kind == 'quotes':
|
|
167
|
+
integer(b['decode_ns'], MAX_DECODE_NS)
|
|
168
|
+
stamp = integer(b['received_unix_ms'])
|
|
169
|
+
require(UNIX_MS[0] <= stamp < UNIX_MS[1], 'Implausible local receipt clock')
|
|
170
|
+
if received is None:
|
|
171
|
+
received = previous_stamp = stamp
|
|
172
|
+
require(previous_stamp <= stamp <= received + MAX_SESSION_MS, 'Local receipt clock regression/span')
|
|
173
|
+
previous_stamp = stamp
|
|
174
|
+
lateness = b['replay_lateness_ms']
|
|
175
|
+
if lateness is not None:
|
|
176
|
+
measurement(lateness, MAX_SESSION_MS)
|
|
177
|
+
# A replay is paced (measured) or unpaced (null) throughout.
|
|
178
|
+
paced = (lateness is not None) if paced is None else paced
|
|
179
|
+
require(paced == (lateness is not None), 'Inconsistent replay pacing')
|
|
180
|
+
quotes = b.get('quotes', [])
|
|
181
|
+
require(isinstance(quotes, list) and len(quotes) <= len(identities), 'Invalid quote count')
|
|
182
|
+
previous_issue = -1
|
|
183
|
+
for q in quotes:
|
|
184
|
+
require(isinstance(q, dict) and set(q) == QUOTE_KEYS, 'Unknown or missing quote fields')
|
|
185
|
+
issue = integer(q['issue_id'], 2**32-1)
|
|
186
|
+
require(issue > previous_issue and identities.get(issue) == code(q['code']), 'Quote identity mismatch/duplicate')
|
|
187
|
+
previous_issue = issue
|
|
188
|
+
frame, clock = integer(q['frame'], 2**32-1), integer(q['source_time_us'], now)
|
|
189
|
+
previous = clocks.get(issue, (0, 0))
|
|
190
|
+
require(frame >= previous[0] and clock >= previous[1], 'Quote frame/time regression')
|
|
191
|
+
clocks[issue] = (frame, clock)
|
|
192
|
+
for key, value in q.items():
|
|
193
|
+
if key != 'code':
|
|
194
|
+
integer(value)
|
|
195
|
+
fingerprint.quote(seq, q)
|
|
196
|
+
if seq == 0:
|
|
197
|
+
require(len(quotes) == len(identities), 'Incomplete bootstrap')
|
|
198
|
+
if kind == 'end':
|
|
199
|
+
require(b['frames'] == seq and b['quote_updates'] == updates, 'End summary mismatch')
|
|
200
|
+
measurement(b['replay_wall_ms'], MAX_SESSION_MS)
|
|
201
|
+
ended = True
|
|
202
|
+
elif kind == 'quotes':
|
|
203
|
+
updates += len(quotes)
|
|
204
|
+
seq += 1
|
|
205
|
+
last, count = now, count + len(quotes)
|
|
206
|
+
require(ended, 'Recording has no clean end')
|
|
207
|
+
summary = dict(source=source, trading_date=date, first_source_time_us=first,
|
|
208
|
+
last_source_time_us=last, codes=sorted(identities.values()), batches=seq,
|
|
209
|
+
quote_updates=count, expanded_bytes=expanded)
|
|
210
|
+
return summary, fingerprint, market
|
|
211
|
+
|
|
212
|
+
def build_reference(stream):
|
|
213
|
+
"""Fingerprint a complete, trusted replay of every issue in a source's market."""
|
|
214
|
+
summary, fingerprint, market = _scan(stream)
|
|
215
|
+
require(set(fingerprint.issues) == set(range(market)), 'Reference must cover the whole market')
|
|
216
|
+
return dict(source=summary['source'], trading_date=summary['trading_date'], market_issue_count=market,
|
|
217
|
+
batches=summary['batches'], timeline=fingerprint.timeline.hexdigest(),
|
|
218
|
+
issues=[fingerprint.digest(i) for i in range(market)])
|
|
219
|
+
|
|
220
|
+
def synthetic_recording():
|
|
221
|
+
"""The only accepted synthetic_test recording: a one-issue publication probe."""
|
|
222
|
+
master = {**dict.fromkeys(MASTER_KEYS, 0), 'issue_id': 0, 'code': '0000', 'lot_size': 100, 'name': 'Synthetic fixture'}
|
|
223
|
+
quote = {**dict.fromkeys(QUOTE_KEYS, 0), 'issue_id': 0, 'code': '0000', 'frame': 1, 'max_frame': 1,
|
|
224
|
+
'source_time_us': 100, 'indicative_price10': 1230, 'indicative_volume': 100}
|
|
225
|
+
return [dict(type='bootstrap', seq=0, source='synthetic_test', trading_date='20210927', source_time_us=100,
|
|
226
|
+
input_transport={'kind': 'local_cache'}, source_timestamp_origin='brisk_decoder_unverified',
|
|
227
|
+
exchange_delay_ms=None, market_issue_count=1, master=[master], quotes=[quote]),
|
|
228
|
+
dict(type='quotes', seq=1, source_time_us=110, received_unix_ms=1632700800000, decode_ns=1000,
|
|
229
|
+
replay_lateness_ms=0, quotes=[{**quote, 'frame': 2, 'max_frame': 2, 'source_time_us': 110,
|
|
230
|
+
'indicative_price10': 1240}]),
|
|
231
|
+
dict(type='end', seq=2, source_time_us=110, frames=2, quote_updates=1, replay_wall_ms=0.5)]
|
|
232
|
+
|
|
233
|
+
def load_references():
|
|
234
|
+
references = {'synthetic_test': build_reference(io.BytesIO(b''.join(map(encode, synthetic_recording()))))}
|
|
235
|
+
for path in sorted(REFERENCE_DIR.glob('*.json')):
|
|
236
|
+
reference = json.loads(path.read_text())
|
|
237
|
+
references[reference['source']] = reference
|
|
238
|
+
return references
|
|
239
|
+
|
|
240
|
+
REFERENCES = load_references()
|
|
241
|
+
|
|
242
|
+
def validate_stream(stream, references=None):
|
|
243
|
+
"""Validate a canonical recording against its source's reference replay."""
|
|
244
|
+
summary, fingerprint, market = _scan(stream)
|
|
245
|
+
reference = (REFERENCES if references is None else references).get(summary['source'])
|
|
246
|
+
require(reference is not None, 'No reference replay for this source')
|
|
247
|
+
require(summary['trading_date'] == reference['trading_date'] and market == reference['market_issue_count']
|
|
248
|
+
and summary['batches'] == reference['batches'] and fingerprint.timeline.hexdigest() == reference['timeline'],
|
|
249
|
+
'Recording is not a complete reference replay')
|
|
250
|
+
issues = reference['issues']
|
|
251
|
+
require(all(i < len(issues) and fingerprint.digest(i) == issues[i] for i in fingerprint.issues),
|
|
252
|
+
'Market data differs from the reference replay')
|
|
253
|
+
return summary
|
|
254
|
+
|
|
255
|
+
def digest(path):
|
|
256
|
+
h = hashlib.sha256()
|
|
257
|
+
with open(path, 'rb') as f:
|
|
258
|
+
for chunk in iter(lambda: f.read(1024 * 1024), b''):
|
|
259
|
+
h.update(chunk)
|
|
260
|
+
return h.hexdigest()
|
|
261
|
+
|
|
262
|
+
def validate_manifest(m):
|
|
263
|
+
fields = {'schema', 'sha256', 'bytes', 'summary', 'contributor', 'license', 'redistribution_permitted'}
|
|
264
|
+
require(isinstance(m, dict) and set(m) == fields, 'Invalid manifest fields')
|
|
265
|
+
require(m['schema'] == SCHEMA and isinstance(m['sha256'], str) and re.fullmatch('[0-9a-f]{64}', m['sha256']), 'Invalid schema/hash')
|
|
266
|
+
require(0 < integer(m['bytes'], MAX_COMPRESSED), 'Empty upload')
|
|
267
|
+
require(isinstance(m['contributor'], str) and re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', m['contributor']), 'Invalid contributor alias')
|
|
268
|
+
require(m['license'] in {'CC0-1.0', 'CC-BY-4.0'} and m['redistribution_permitted'] is True, 'Redistribution declaration required')
|
|
269
|
+
summary = m['summary']
|
|
270
|
+
require(isinstance(summary, dict) and set(summary) == SUMMARY_KEYS and summary['source'] in SOURCES, 'Invalid summary')
|
|
271
|
+
require(isinstance(summary['trading_date'], str) and re.fullmatch(r'\d{8}', summary['trading_date']), 'Invalid summary date')
|
|
272
|
+
for key in SUMMARY_KEYS - {'source', 'trading_date', 'codes'}:
|
|
273
|
+
integer(summary[key])
|
|
274
|
+
codes = summary['codes']
|
|
275
|
+
require(isinstance(codes, list) and 0 < len(codes) <= 20000, 'Invalid summary codes')
|
|
276
|
+
for value in codes:
|
|
277
|
+
code(value)
|
|
278
|
+
return m
|
|
279
|
+
|
|
280
|
+
def inspect_package(path, manifest, references=None):
|
|
281
|
+
validate_manifest(manifest)
|
|
282
|
+
require(path.stat().st_size == manifest['bytes'] and digest(path) == manifest['sha256'], 'Size/hash mismatch')
|
|
283
|
+
with gzip.open(path, 'rb') as stream:
|
|
284
|
+
summary = validate_stream(stream, references)
|
|
285
|
+
require(summary == manifest['summary'], 'Summary mismatch')
|
|
286
|
+
return summary
|
|
287
|
+
|
|
288
|
+
class _Tee:
|
|
289
|
+
def __init__(self, stream, sink):
|
|
290
|
+
self.stream, self.sink = stream, sink
|
|
291
|
+
|
|
292
|
+
def readline(self, size):
|
|
293
|
+
line = self.stream.readline(size)
|
|
294
|
+
self.sink.write(line)
|
|
295
|
+
return line
|
|
296
|
+
|
|
297
|
+
def repack(path, manifest, output, references=None):
|
|
298
|
+
"""Validate an upload and write the archive's own compression of its content.
|
|
299
|
+
|
|
300
|
+
Published bytes never include the uploader's gzip container (header fields,
|
|
301
|
+
extra members, padding or deflate choices); only validated canonical lines.
|
|
302
|
+
"""
|
|
303
|
+
validate_manifest(manifest)
|
|
304
|
+
require(path.stat().st_size == manifest['bytes'] and digest(path) == manifest['sha256'], 'Size/hash mismatch')
|
|
305
|
+
with gzip.open(path, 'rb') as source, output.open('wb') as raw, \
|
|
306
|
+
gzip.GzipFile(fileobj=raw, mode='wb', filename='', mtime=0, compresslevel=GZIP_LEVEL) as sink:
|
|
307
|
+
summary = validate_stream(_Tee(source, sink), references)
|
|
308
|
+
require(summary == manifest['summary'], 'Summary mismatch')
|
|
309
|
+
return validate_manifest({**manifest, 'sha256': digest(output), 'bytes': output.stat().st_size})
|
|
310
|
+
|
|
311
|
+
# Timing reports: what an SBI session contributes instead of market data.
|
|
312
|
+
TIMING_SCHEMA = 'brisk-timing-v1'
|
|
313
|
+
TIMING_SOURCES = {'sbi_live'}
|
|
314
|
+
TIMING_KEYS = {'schema', 'source', 'trading_date', 'first_minute', 'last_minute', 'frames', 'stalls',
|
|
315
|
+
'client_version', 'decode_ms', 'source_age_ms', 'interarrival_ms', 'contributor', 'license'}
|
|
316
|
+
QUANTILES = ('p50', 'p90', 'p99', 'max')
|
|
317
|
+
# Bounds per distribution (ms). Source age can be negative when local and feed clocks disagree.
|
|
318
|
+
TIMING_BOUNDS = {'decode_ms': (0, 10_000), 'source_age_ms': (-600_000, 600_000), 'interarrival_ms': (0, 3_600_000)}
|
|
319
|
+
MAX_TIMING_BYTES = 2048
|
|
320
|
+
MIN_TIMING_FRAMES = 100
|
|
321
|
+
|
|
322
|
+
def validate_timing(report, today=None):
|
|
323
|
+
"""A timing report: exact fields, bounded microsecond-precision distributions, no market data.
|
|
324
|
+
|
|
325
|
+
`today` (a date) additionally requires a trading date within the last 30 days.
|
|
326
|
+
"""
|
|
327
|
+
require(isinstance(report, dict) and set(report) == TIMING_KEYS, 'Invalid timing report fields')
|
|
328
|
+
require(report['schema'] == TIMING_SCHEMA and report['source'] in TIMING_SOURCES, 'Invalid timing schema/source')
|
|
329
|
+
date = report['trading_date']
|
|
330
|
+
require(isinstance(date, str) and re.fullmatch(r'\d{8}', date), 'Invalid timing date')
|
|
331
|
+
day = dt.datetime.strptime(date, '%Y%m%d').date()
|
|
332
|
+
if today is not None:
|
|
333
|
+
require(0 <= (today - day).days <= 30, 'Timing report date out of range')
|
|
334
|
+
minutes = [report['first_minute'], report['last_minute']]
|
|
335
|
+
require(all(isinstance(m, str) and re.fullmatch(r'([01]\d|2[0-3]):[0-5]\d', m) for m in minutes)
|
|
336
|
+
and minutes[0] <= minutes[1], 'Invalid session minutes')
|
|
337
|
+
frames = integer(report['frames'], 10_000_000)
|
|
338
|
+
require(frames >= MIN_TIMING_FRAMES and integer(report['stalls']) <= frames, 'Invalid frame counts')
|
|
339
|
+
require(isinstance(report['client_version'], str) and re.fullmatch(r'\d{1,4}\.\d{1,4}\.\d{1,4}', report['client_version']),
|
|
340
|
+
'Invalid client version')
|
|
341
|
+
require(isinstance(report['contributor'], str) and re.fullmatch(r'[A-Za-z0-9_.-]{1,64}', report['contributor']),
|
|
342
|
+
'Invalid contributor alias')
|
|
343
|
+
require(report['license'] in {'CC0-1.0', 'CC-BY-4.0'}, 'Invalid license')
|
|
344
|
+
for name, (low, high) in TIMING_BOUNDS.items():
|
|
345
|
+
values = report[name]
|
|
346
|
+
require(isinstance(values, dict) and set(values) == set(QUANTILES), f'Invalid {name}')
|
|
347
|
+
series = [values[q] for q in QUANTILES]
|
|
348
|
+
require(all(type(v) in (int, float) and low <= v <= high and round(v, 3) == v for v in series)
|
|
349
|
+
and series == sorted(series), f'Invalid {name}')
|
|
350
|
+
require(len(encode(report)) <= MAX_TIMING_BYTES, 'Timing report too large')
|
|
351
|
+
return report
|