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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 obichan117
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
briskapi/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ """Python API for BRiSK auction data: recordings, the shared archive and the public demo.
2
+
3
+ import briskapi
4
+
5
+ feed = briskapi.connect(web=True) # live: state updates as frames arrive
6
+ briskapi.Ticker("7203").quote() # current auction quote
7
+ for q in feed.quotes("7203"): ... # or feed.on_quote(callback)
8
+
9
+ briskapi.pull(prefix) # or briskapi.load(path) / briskapi.record(...)
10
+ briskapi.Ticker("7203").quote(at="08:59:59")
11
+ briskapi.Market().imbalances(top=10).to_pandas()
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from briskapi import cli as _cli
16
+ from briskapi._archive import Archive
17
+ from briskapi._live import Feed, connect as _connect, record as _record, stream
18
+ from briskapi._market import Market, Ticker
19
+ from briskapi._recording import JST, BriskError, NotFoundError, Recording, Table
20
+
21
+ __version__ = '0.2.0'
22
+ __all__ = ['JST', 'Archive', 'BriskError', 'Feed', 'Market', 'NotFoundError', 'Recording', 'Table', 'Ticker',
23
+ 'connect', 'consent', 'current', 'load', 'pull', 'record', 'recordings', 'stream']
24
+
25
+ _current: Recording | Feed | None = None
26
+
27
+
28
+ def load(source) -> Recording | Feed:
29
+ """Use a live Feed or a recording (path, directory or Recording) as the default for Ticker and Market."""
30
+ global _current
31
+ _current = source if isinstance(source, (Recording, Feed)) else Recording(source)
32
+ return _current
33
+
34
+
35
+ def current() -> Recording | Feed:
36
+ if _current is None:
37
+ raise BriskError('Nothing loaded: use briskapi.connect(...), briskapi.load(path), briskapi.pull(prefix) or briskapi.record(...)')
38
+ return _current
39
+
40
+
41
+ def connect(**options) -> Feed:
42
+ """Start a live feed (see Feed) and make it the default for Ticker and Market."""
43
+ return load(_connect(**options))
44
+
45
+
46
+ def recordings(date=None, source=None) -> Table:
47
+ """Published archive recordings (see Archive.recordings)."""
48
+ return Archive().recordings(date, source)
49
+
50
+
51
+ def pull(prefix, output=None) -> Recording:
52
+ """Download and verify an archive recording, then make it the default."""
53
+ return load(Archive().pull(prefix, output))
54
+
55
+
56
+ def record(output, **options) -> Recording:
57
+ """Record the public demo (see briskapi._live.record), contribute per consent and make it the default."""
58
+ return load(_record(output, **options))
59
+
60
+
61
+ def consent(accept=False, revoke=False, contributor=None, license=None) -> dict | None:
62
+ """Show (no arguments), accept or revoke automatic contribution. See PRIVACY.md."""
63
+ if accept and revoke:
64
+ raise ValueError('Choose accept or revoke')
65
+ if accept:
66
+ return _cli.save_consent(True, contributor, license)
67
+ return _cli.save_consent(False) if revoke else _cli.load_consent()
briskapi/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from briskapi.cli import main
2
+
3
+ main()
briskapi/_archive.py ADDED
@@ -0,0 +1,62 @@
1
+ """The shared public archive: list, pull (verified and cached) and contribute."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+
8
+ from briskapi import cli
9
+ from briskapi._recording import Recording, Table
10
+
11
+
12
+ def cache_dir() -> Path:
13
+ return Path(os.environ.get('XDG_CACHE_HOME') or Path.home() / '.cache') / 'brisk'
14
+
15
+
16
+ class Archive:
17
+ """Recordings published in the Tokyo archive. Reading needs no AWS account.
18
+
19
+ archive = Archive()
20
+ rows = archive.recordings(source="historical_mock")
21
+ rec = archive.pull(rows[0]["prefix"])
22
+ """
23
+
24
+ def __init__(self, config=None, s3=None):
25
+ self.config = cli.settings(Path(config) if config else None)
26
+ self._s3 = s3
27
+
28
+ @property
29
+ def s3(self):
30
+ if self._s3 is None:
31
+ self._s3 = cli.client(self.config)
32
+ return self._s3
33
+
34
+ def recordings(self, date=None, source=None) -> Table:
35
+ """Published recordings, optionally filtered by YYYYMMDD trading date and source."""
36
+ rows = []
37
+ for prefix, m in cli.manifests(self.s3, self.config['bucket'], date):
38
+ s = m['summary']
39
+ if source is None or s['source'] == source:
40
+ rows.append({'prefix': prefix, 'source': s['source'], 'trading_date': s['trading_date'],
41
+ 'securities': len(s['codes']), 'batches': s['batches'],
42
+ 'quote_updates': s['quote_updates'], 'bytes': m['bytes'],
43
+ 'contributor': m['contributor'], 'license': m['license'], 'sha256': m['sha256']})
44
+ return Table(rows)
45
+
46
+ def timing(self, date=None) -> Table:
47
+ """Published timing-only reports (from SBI BRiSK sessions), optionally for one YYYYMMDD date."""
48
+ return Table({'key': key, **report} for key, report in cli.timing_reports(self.s3, self.config['bucket'], date))
49
+
50
+ def pull(self, prefix, output=None) -> Recording:
51
+ """Download, verify and decode one recording. Without `output`, downloads are cached."""
52
+ if not re.fullmatch(r'archive/\d{8}/[0-9a-f]{64}', prefix):
53
+ raise ValueError('Invalid archive prefix')
54
+ target = Path(output) if output else cache_dir() / prefix
55
+ # Cache entries only appear after verification (pull moves them atomically).
56
+ if output or not target.exists():
57
+ cli.pull(self.s3, self.config['bucket'], prefix, target)
58
+ return Recording(target)
59
+
60
+ def contribute(self, directory, timeout=660) -> dict:
61
+ """Upload a prepared package and wait for automatic validation and publication."""
62
+ return cli.contribute(Path(directory), self.config['api_url'], timeout)
briskapi/_live.py ADDED
@@ -0,0 +1,376 @@
1
+ """Live consumption of the pinned public demo, plus recording and automatic contribution."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import queue
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import threading
13
+ from typing import Callable, Iterator
14
+ import warnings
15
+
16
+ from briskapi import cli
17
+ from briskapi._recording import BriskError, NotFoundError, Recording, Table, micros, quote_view
18
+ from briskapi.timing import TimingStats
19
+
20
+ DECODER = cli.DECODER
21
+ _END = object()
22
+
23
+
24
+ def _codes(codes):
25
+ return codes if isinstance(codes, str) else ','.join(map(str, codes))
26
+
27
+
28
+ def _decoder(web, cache, codes, speed, limit_frames, node):
29
+ if bool(web) == bool(cache):
30
+ raise ValueError('Select exactly one of web=True or cache=DIR')
31
+ command = [node, str(DECODER), *(['--web'] if web else ['--cache', str(cache)]), '--speed', str(speed)]
32
+ if codes:
33
+ command += ['--codes', _codes(codes)]
34
+ if limit_frames:
35
+ command += ['--limit-frames', str(limit_frames)]
36
+ return subprocess.Popen(command, stdout=subprocess.PIPE)
37
+
38
+
39
+ def _stop(process):
40
+ if process.poll() is None:
41
+ process.kill()
42
+ process.stdout.close()
43
+ process.wait()
44
+
45
+
46
+ def stream(web=False, cache=None, codes=None, speed=1, limit_frames=None, node='node') -> Iterator[dict]:
47
+ """Yield raw decoded batches as each frame replays (bootstrap, quote deltas, end)."""
48
+ process = _decoder(web, cache, codes, speed, limit_frames, node)
49
+ try:
50
+ for line in process.stdout:
51
+ yield json.loads(line)
52
+ finally:
53
+ _stop(process)
54
+ if process.returncode:
55
+ raise BriskError(f'Decoder exited with {process.returncode}')
56
+
57
+
58
+ def _consent(contribute, limit_frames):
59
+ """(saved alias/license or None, whether to upload) for a session under saved consent."""
60
+ choice = cli.load_consent()
61
+ declared = bool(choice and choice['enabled']) and limit_frames is None
62
+ if contribute and not declared:
63
+ raise BriskError('contribute=True needs briskapi.consent(accept=True) and a complete replay')
64
+ if contribute is None and choice is None:
65
+ warnings.warn('Contribution is undecided, so this session stays local. Decide once with '
66
+ '`brisk consent --accept|--revoke` or briskapi.consent(accept=True|revoke=True); see https://github.com/honvl/BRiSKapi/blob/main/PRIVACY.md',
67
+ stacklevel=3)
68
+ upload = declared and contribute is not False and os.environ.get('BRISK_CONTRIBUTE') != '0'
69
+ return (choice if declared else None), upload
70
+
71
+
72
+ class Feed:
73
+ """Live auction state, updated in a background thread as the decoder emits frames.
74
+
75
+ with briskapi.connect(web=True, codes=["7203", "6758"]) as feed:
76
+ feed.on_quote(lambda q: print(q["code"], q["indicative_price"]))
77
+ for q in feed.quotes("7203"): # blocks for each update
78
+ ...
79
+ briskapi.Ticker("7203").quote() # current state, any time
80
+
81
+ Consumers apply backpressure: a slow `quotes()` iterator or callback slows the
82
+ feed instead of dropping updates. A complete session is contributed to the
83
+ archive when saved consent allows (see PRIVACY.md); pass contribute=False to
84
+ keep it local.
85
+ """
86
+
87
+ manifest = None
88
+
89
+ def __init__(self, web=False, cache=None, codes=None, speed=1, limit_frames=None, contribute=None,
90
+ history=False, node='node', *, command=None, env=None, timing=None):
91
+ # `command` runs another decoder host (SBI live). Its market data is never
92
+ # contributed; with `timing` set, a timing-only summary may be.
93
+ if command is None:
94
+ choice, upload = _consent(contribute, limit_frames)
95
+ self._choice, self._timing = (choice if upload else None), None
96
+ else:
97
+ choice, upload = _consent(contribute, None) if timing else (None, False)
98
+ self._choice = None
99
+ self._timing = (TimingStats(timing), choice) if upload else None
100
+ self.timing_contribution = None
101
+ self._history = {} if history else None
102
+ self._quotes: dict[int, dict] = {}
103
+ self._bootstrap = None
104
+ self._listeners: list = []
105
+ self._changed = threading.Condition()
106
+ self._closing = False
107
+ self.status, self.error, self.seq, self.contribution = 'starting', None, -1, None
108
+ self._tmp = tempfile.TemporaryDirectory() if self._choice else None
109
+ self._process = (subprocess.Popen(command, stdout=subprocess.PIPE, env=env) if command
110
+ else _decoder(web, cache, codes, speed, limit_frames, node))
111
+ self._thread = threading.Thread(target=self._run, name='brisk-feed', daemon=True)
112
+ self._thread.start()
113
+
114
+ def __repr__(self):
115
+ return f'Feed(status={self.status!r}, seq={self.seq})'
116
+
117
+ def __enter__(self):
118
+ return self
119
+
120
+ def __exit__(self, *exc):
121
+ self.close()
122
+
123
+ # Recording-compatible reads used by Ticker and Market.
124
+ @property
125
+ def bootstrap(self) -> dict:
126
+ self.ready()
127
+ return self._bootstrap
128
+
129
+ @property
130
+ def trading_date(self) -> str:
131
+ return self.bootstrap['trading_date']
132
+
133
+ @property
134
+ def source(self) -> str:
135
+ return self.bootstrap['source']
136
+
137
+ @property
138
+ def codes(self) -> list[str]:
139
+ return [m['code'] for m in self.bootstrap['master']]
140
+
141
+ def issue(self, code) -> dict:
142
+ for entry in self.bootstrap['master']:
143
+ if entry['code'] == str(code):
144
+ return entry
145
+ raise NotFoundError(f'{code} is not in this feed')
146
+
147
+ def state(self, at=None) -> dict[int, dict]:
148
+ """Current raw quote per issue. Live feeds hold only the present; record() for time travel."""
149
+ if at is not None:
150
+ raise ValueError('A live feed holds current state only; use a Recording for past times')
151
+ self.ready()
152
+ with self._changed:
153
+ return dict(self._quotes)
154
+
155
+ def updates(self, code, start=None, end=None) -> Iterator[dict]:
156
+ """Raw updates received so far for one security (needs history=True)."""
157
+ if self._history is None:
158
+ raise BriskError('Pass history=True to briskapi.connect() to retain updates')
159
+ issue = self.issue(code)['issue_id']
160
+ low, high = micros(self.trading_date, start), micros(self.trading_date, end)
161
+ with self._changed:
162
+ retained = list(self._history.get(issue, ()))
163
+ return (q for q in retained if (low is None or q['source_time_us'] >= low)
164
+ and (high is None or q['source_time_us'] <= high))
165
+
166
+ # Live consumption.
167
+ def ready(self, timeout=60) -> Feed:
168
+ """Wait until the bootstrap (master and initial quotes) has been applied."""
169
+ with self._changed:
170
+ if not self._changed.wait_for(lambda: self._bootstrap is not None or self.status in {'failed', 'closed', 'completed'}, timeout):
171
+ raise TimeoutError('Feed did not start in time')
172
+ if self._bootstrap is None:
173
+ raise BriskError(f'Feed {self.status} before bootstrap: {self.error}')
174
+ return self
175
+
176
+ def on_quote(self, callback: Callable[[dict], object], codes=None, raw=False) -> Callable[[], None]:
177
+ """Call `callback(quote)` on the feed thread for each update; returns an unsubscribe function.
178
+
179
+ The callback first receives each selected security's current quote.
180
+ An exception in a callback stops the feed and is re-raised by wait().
181
+ """
182
+ return self._listen(callback, codes, raw)[1]
183
+
184
+ def quotes(self, codes=None, raw=False, timeout=None) -> Iterator[dict]:
185
+ """Iterate updates as they arrive, starting with each selected security's current quote."""
186
+ inbox: queue.Queue = queue.Queue(maxsize=10_000)
187
+ active = [True]
188
+
189
+ def deliver(item):
190
+ # Backpressure, but never wedge the feed on an abandoned iterator.
191
+ while active[0]:
192
+ try:
193
+ return inbox.put(item, timeout=0.2)
194
+ except queue.Full:
195
+ pass
196
+
197
+ current, remove = self._listen(deliver, codes, raw, end=lambda: deliver(_END), replay=False)
198
+ try:
199
+ yield from current
200
+ while (item := inbox.get(timeout=timeout)) is not _END:
201
+ yield item
202
+ except queue.Empty:
203
+ raise TimeoutError('No update within timeout') from None
204
+ finally:
205
+ active[0] = False
206
+ remove()
207
+ self._raise()
208
+
209
+ def snapshot(self, raw=False) -> Table:
210
+ state, date = self.state(), self.trading_date
211
+ return Table(state[i] if raw else quote_view(state[i], date) for i in sorted(state))
212
+
213
+ def wait(self, timeout=None) -> Feed:
214
+ """Block until the replay ends (and any contribution finishes); raise if it failed."""
215
+ self._thread.join(timeout)
216
+ if self._thread.is_alive():
217
+ raise TimeoutError('Feed still running')
218
+ self._raise()
219
+ return self
220
+
221
+ def close(self):
222
+ """Stop the decoder. A session closed before its end is never contributed."""
223
+ self._closing = True
224
+ if self._process.poll() is None:
225
+ self._process.kill()
226
+ self._thread.join()
227
+
228
+ def _raise(self):
229
+ if self.status == 'failed':
230
+ raise BriskError(f'Feed failed: {self.error}') from self.error
231
+
232
+ def _listen(self, deliver, codes, raw, end=None, replay=True):
233
+ self.ready()
234
+ wanted = None if codes is None else {self.issue(c)['issue_id'] for c in ([codes] if isinstance(codes, str) else codes)}
235
+ date = self._bootstrap['trading_date']
236
+ listener = (wanted, deliver, (lambda q: q) if raw else (lambda q: quote_view(q, date)), end)
237
+ with self._changed:
238
+ # Snapshot and registration are atomic, so no update is missed or reordered.
239
+ current = [listener[2](self._quotes[i]) for i in sorted(self._quotes) if wanted is None or i in wanted]
240
+ if self.status == 'running':
241
+ self._listeners.append(listener)
242
+ elif end:
243
+ end()
244
+ if replay:
245
+ for quote in current:
246
+ deliver(quote)
247
+
248
+ def remove():
249
+ with self._changed:
250
+ if listener in self._listeners:
251
+ self._listeners.remove(listener)
252
+ return current, remove
253
+
254
+ def _run(self):
255
+ recorder = None
256
+ try:
257
+ if self._tmp:
258
+ recorder = open(Path(self._tmp.name) / 'events.jsonl', 'wb')
259
+ ended = False
260
+ for line in self._process.stdout:
261
+ batch = json.loads(line)
262
+ if batch.get('seq') != self.seq + 1 or (self.seq < 0) != (batch.get('type') == 'bootstrap'):
263
+ raise BriskError(f'Stream sequence error at {batch.get("seq")}')
264
+ if recorder:
265
+ recorder.write(line)
266
+ ended = batch['type'] == 'end'
267
+ if self._timing:
268
+ self._timing[0].add(batch)
269
+ self._apply(batch)
270
+ if self._closing:
271
+ raise BriskError('closed')
272
+ if self._process.wait():
273
+ raise BriskError(f'Decoder exited with {self._process.returncode}')
274
+ if not ended:
275
+ raise BriskError('Decoder closed before replay completion')
276
+ with self._changed:
277
+ self.status = 'completed'
278
+ self._changed.notify_all()
279
+ self._finish()
280
+ if recorder:
281
+ recorder.close()
282
+ self._contribute()
283
+ except Exception as error: # noqa: BLE001 - every failure ends the feed visibly
284
+ with self._changed:
285
+ self.status, self.error = ('closed', None) if self._closing else ('failed', error)
286
+ self._changed.notify_all()
287
+ self._finish()
288
+ finally:
289
+ _stop(self._process)
290
+ if self._timing and self.status in {'completed', 'closed'}:
291
+ self._contribute_timing()
292
+ if recorder:
293
+ recorder.close()
294
+ if self._tmp:
295
+ self._tmp.cleanup()
296
+
297
+ def _apply(self, batch):
298
+ quotes = batch.get('quotes', ())
299
+ with self._changed:
300
+ if batch['type'] == 'bootstrap':
301
+ self._bootstrap = {k: v for k, v in batch.items() if k != 'quotes'}
302
+ self.status = 'running'
303
+ self._quotes.update((q['issue_id'], q) for q in quotes)
304
+ if self._history is not None:
305
+ for q in quotes:
306
+ self._history.setdefault(q['issue_id'], []).append(q)
307
+ self.seq = batch['seq']
308
+ listeners = list(self._listeners)
309
+ self._changed.notify_all()
310
+ for wanted, deliver, view, _ in listeners:
311
+ for q in quotes:
312
+ if wanted is None or q['issue_id'] in wanted:
313
+ deliver(view(q))
314
+
315
+ def _finish(self):
316
+ with self._changed:
317
+ listeners, self._listeners = self._listeners, []
318
+ for *_, end in listeners:
319
+ if end:
320
+ end()
321
+
322
+ def _contribute_timing(self):
323
+ stats, choice = self._timing
324
+ try:
325
+ report = stats.report(choice['contributor'], choice['license'])
326
+ if report:
327
+ self.timing_contribution = cli.contribute_timing(report, cli.settings()['api_url'])
328
+ except Exception as error: # noqa: BLE001 - the session itself succeeded; report, do not fail
329
+ self.timing_contribution = {'status': 'failed', 'error': str(error)}
330
+ warnings.warn(f'Timing contribution failed: {error}')
331
+
332
+ def _contribute(self):
333
+ package = Path(self._tmp.name) / 'package'
334
+ try:
335
+ cli.package(Path(self._tmp.name) / 'events.jsonl', package, self._choice['contributor'], self._choice['license'])
336
+ self.contribution = cli.contribute(package, cli.settings()['api_url'], verify=False, out=sys.stderr)
337
+ except Exception as error: # noqa: BLE001 - market data was delivered; report, do not fail
338
+ self.contribution = {'status': 'failed', 'error': str(error)}
339
+ warnings.warn(f'Automatic contribution failed: {error}')
340
+
341
+
342
+ def connect(web=False, cache=None, codes=None, speed=1, limit_frames=None, contribute=None, history=False,
343
+ node='node', timeout=60) -> Feed:
344
+ """Start a live feed and return once its bootstrap state is available."""
345
+ feed = Feed(web, cache, codes, speed, limit_frames, contribute, history, node)
346
+ try:
347
+ return feed.ready(timeout)
348
+ except BaseException:
349
+ feed.close()
350
+ raise
351
+
352
+
353
+ def record(output, web=False, cache=None, codes=None, speed=1, limit_frames=None, contribute=None,
354
+ binary=None) -> Recording:
355
+ """Record a demo replay into `output` and contribute it according to saved consent.
356
+
357
+ contribute=None follows `briskapi.consent()` (and BRISK_CONTRIBUTE=0 opts out),
358
+ False keeps the recording local, True requires saved consent. Partial replays
359
+ (limit_frames) always stay local.
360
+ """
361
+ output = Path(output)
362
+ choice, upload = _consent(contribute, limit_frames)
363
+ with tempfile.TemporaryDirectory() as tmp:
364
+ events = Path(tmp) / 'events.jsonl'
365
+ cli.record_events(events, web, cache, codes and _codes(codes), limit_frames, speed, binary)
366
+ if choice is None:
367
+ output.mkdir(parents=True, exist_ok=True)
368
+ if (output / 'events.jsonl').exists():
369
+ raise BriskError(f'{output / "events.jsonl"} already exists')
370
+ shutil.move(str(events), str(output / 'events.jsonl'))
371
+ return Recording(output)
372
+ cli.package(events, output, choice['contributor'], choice['license'])
373
+ recording = Recording(output)
374
+ if upload:
375
+ recording.contribution = cli.contribute(output, cli.settings()['api_url'], verify=False)
376
+ return recording
briskapi/_market.py ADDED
@@ -0,0 +1,136 @@
1
+ """Ticker and Market: auction data from a recording or live feed, plus SBI BRiSK data."""
2
+ from __future__ import annotations
3
+
4
+ from briskapi._recording import NotFoundError, Recording, Table, master_view, quote_view, timestamp
5
+
6
+
7
+ def _default():
8
+ from briskapi import current
9
+ return current()
10
+
11
+
12
+ def _sbi(client):
13
+ from briskapi import sbi
14
+ return client or sbi._default()
15
+
16
+
17
+ class Ticker:
18
+ """Per-security auction data.
19
+
20
+ t = Ticker("7203")
21
+ t.info(); t.quote(); t.quote(at="09:00:00"); t.history().to_pandas()
22
+ t.candles("5m"); t.margin() # SBI BRiSK session (briskapi.sbi.login)
23
+ """
24
+
25
+ def __init__(self, code, recording: Recording | None = None, sbi=None):
26
+ self.code = str(code)
27
+ self._recording = recording
28
+ self._sbi = sbi
29
+
30
+ def __repr__(self):
31
+ return f'Ticker({self.code!r})'
32
+
33
+ @property
34
+ def recording(self) -> Recording:
35
+ return self._recording or _default()
36
+
37
+ def info(self) -> dict:
38
+ """Master entry: name, lot size, tick type, base price and daily limits (yen)."""
39
+ return master_view(self.recording.issue(self.code))
40
+
41
+ def quote(self, at=None, raw=False) -> dict:
42
+ """Latest auction quote at a JST time (default: end of recording)."""
43
+ rec = self.recording
44
+ issue = rec.issue(self.code)['issue_id']
45
+ quote = rec.state(at).get(issue)
46
+ if quote is None:
47
+ raise NotFoundError(f'No quote for {self.code} at {at}')
48
+ return quote if raw else quote_view(quote, rec.trading_date)
49
+
50
+ def history(self, start=None, end=None, raw=False) -> Table:
51
+ """Every recorded update for this security, oldest first, optionally bounded by time."""
52
+ rec = self.recording
53
+ return Table(q if raw else quote_view(q, rec.trading_date) for q in rec.updates(self.code, start, end))
54
+
55
+ def auction(self, at=None) -> dict:
56
+ """Indicative auction price/volume and market-order quantities at a time."""
57
+ q = self.quote(at)
58
+ return {key: q[key] for key in ('code', 'time', 'indicative_price', 'indicative_volume', 'indicative_side',
59
+ 'indicative_open_price', 'market_buy_quantity', 'market_sell_quantity')} | {
60
+ 'market_order_imbalance': q['market_buy_quantity'] - q['market_sell_quantity']}
61
+
62
+ def candles(self, interval='1d') -> Table:
63
+ """Price bars from SBI BRiSK: 5m (today), 1d, 1w or 1mo."""
64
+ return _sbi(self._sbi).candles(self.code, interval)
65
+
66
+ def margin(self, days=365) -> Table:
67
+ """Margin balances and stock-lending fees from SBI BRiSK, one row per trading day."""
68
+ return _sbi(self._sbi).margin(self.code, days)
69
+
70
+
71
+ class Market:
72
+ """Market-wide auction data.
73
+
74
+ m = Market()
75
+ m.stocks(); m.snapshot(at="09:00:00"); m.imbalances(top=20)
76
+ m.turnover(); m.lists(); m.events(); m.schedule(); m.watchlist() # SBI BRiSK session
77
+ """
78
+
79
+ def __init__(self, recording: Recording | None = None, sbi=None):
80
+ self._recording = recording
81
+ self._sbi = sbi
82
+
83
+ @property
84
+ def recording(self) -> Recording:
85
+ return self._recording or _default()
86
+
87
+ def stocks(self) -> Table:
88
+ """Master for every security in the recording."""
89
+ return Table(master_view(m) for m in self.recording.bootstrap['master'])
90
+
91
+ def snapshot(self, at=None, raw=False) -> Table:
92
+ """Latest quote of every security at a JST time (default: end of recording)."""
93
+ rec = self.recording
94
+ state = rec.state(at)
95
+ return Table(state[i] if raw else quote_view(state[i], rec.trading_date) for i in sorted(state))
96
+
97
+ def imbalances(self, at=None, top=None) -> Table:
98
+ """Securities ranked by absolute market-order imbalance (buy minus sell quantity)."""
99
+ rows = [{'code': q['code'], 'indicative_price': q['indicative_price'], 'indicative_volume': q['indicative_volume'],
100
+ 'market_buy_quantity': q['market_buy_quantity'], 'market_sell_quantity': q['market_sell_quantity'],
101
+ 'market_order_imbalance': q['market_buy_quantity'] - q['market_sell_quantity']}
102
+ for q in self.snapshot(at)]
103
+ rows.sort(key=lambda r: -abs(r['market_order_imbalance']))
104
+ return Table(rows[:top] if top else rows)
105
+
106
+ def summary(self) -> dict:
107
+ """Source, trading date, coverage and replay clock range."""
108
+ rec = self.recording
109
+ b = rec.bootstrap
110
+ info = {'source': b['source'], 'trading_date': b['trading_date'], 'securities': len(b['master']),
111
+ 'market_issue_count': b['market_issue_count'], 'start': timestamp(b['trading_date'], b['source_time_us'])}
112
+ if rec.manifest:
113
+ info |= {'end': timestamp(b['trading_date'], rec.manifest['summary']['last_source_time_us']),
114
+ 'batches': rec.manifest['summary']['batches'], 'contributor': rec.manifest['contributor'],
115
+ 'license': rec.manifest['license']}
116
+ return info
117
+
118
+ def turnover(self) -> Table:
119
+ """Turnover and shares outstanding for every listed stock (SBI BRiSK)."""
120
+ return _sbi(self._sbi).turnover()
121
+
122
+ def lists(self) -> dict[str, list[str]]:
123
+ """Curated stock lists such as NK225 and recent IPOs (SBI BRiSK)."""
124
+ return _sbi(self._sbi).lists()
125
+
126
+ def events(self, first=0, last=618) -> Table:
127
+ """Market events: basket orders, limit up/down, volume surges (SBI BRiSK)."""
128
+ return _sbi(self._sbi).events(first, last)
129
+
130
+ def schedule(self) -> dict:
131
+ """Trading date, session status and session times (SBI BRiSK)."""
132
+ return _sbi(self._sbi).schedule()
133
+
134
+ def watchlist(self) -> list[str]:
135
+ """Codes saved in your BRiSK watchlist (SBI BRiSK)."""
136
+ return _sbi(self._sbi).watchlist()