outcometick 1.5.0 → 1.5.1

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.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  scripts/publish-sdk-repos.mjs and overwritten wholesale on each publish.
6
6
  An edit made here survives until the next publish and then disappears.
7
7
 
8
- Generated from monorepo revision c210f1ac8cbf693e62cc5b3c5580b0f8663e495a.
8
+ Generated from monorepo revision 3652fc71edb3360170ce324e623b19d0ff994844.
9
9
  -->
10
10
 
11
11
  # outcometick
@@ -15,7 +15,7 @@ import { FIRST_COMPLETE_DAY } from './coverage-window.mjs';
15
15
  export const SCHEMA_VERSION = 1;
16
16
 
17
17
  /** SDK version reported by the docs page and stamped into every report. */
18
- export const SDK_VERSION = '1.5.0';
18
+ export const SDK_VERSION = '1.5.1';
19
19
 
20
20
  // ---------------------------------------------------------------------------
21
21
  // Languages
@@ -0,0 +1,157 @@
1
+ // Types for the data-subscription client, `outcometick/data`.
2
+ //
3
+ // Hand-written against api/subscription-api.mjs, like the strategy SDK's
4
+ // declarations, and guarded the same way — see client/data-types.test.mjs.
5
+
6
+ /** A filter value: one alternative, or several meaning "any of these". */
7
+ export type Filter = string | readonly string[];
8
+
9
+ /** The sentinel naming files that have no value for a dimension. */
10
+ export declare const NO_VALUE: 'none';
11
+
12
+ export declare const DEFAULT_BASE_URL: string;
13
+
14
+ /** An error carrying the status and whatever the API said alongside it. */
15
+ export declare class OutcometickError extends Error {
16
+ readonly status: number;
17
+ /** The API's `error` string. */
18
+ readonly detail: string;
19
+ /**
20
+ * The parsed response. A 403 on a date outside coverage also carries `floor`
21
+ * and `ceiling` — the difference between "no" and "no, but here is the range
22
+ * you do have".
23
+ */
24
+ readonly body: unknown;
25
+ readonly url: string;
26
+ }
27
+
28
+ /** One archive file, as returned by `files()`. */
29
+ export interface FileRow {
30
+ date: string;
31
+ name: string;
32
+ venue: string;
33
+ dataset: string;
34
+ /** null for datasets that are not per-asset. */
35
+ asset: string | null;
36
+ /** null for streams with no period — the settlement feeds. */
37
+ interval: string | null;
38
+ bytes: number;
39
+ sha256: string;
40
+ /** Direct download; needs the key, as a header or `?api_key=`. */
41
+ url: string;
42
+ }
43
+
44
+ export interface FilesResult {
45
+ from: string;
46
+ to: string;
47
+ /** Days actually in scope within [from, to], not the span. */
48
+ days: number;
49
+ count: number;
50
+ /** Total size of the matched files. */
51
+ bytes: number;
52
+ files: FileRow[];
53
+ }
54
+
55
+ export interface FilesQuery {
56
+ /** One day. Cannot be combined with from/to. */
57
+ date?: string;
58
+ /** Inclusive start. Defaults to the newest day in scope. */
59
+ from?: string;
60
+ /** Inclusive end. Defaults to `from`. */
61
+ to?: string;
62
+ venue?: Filter;
63
+ dataset?: Filter;
64
+ asset?: Filter;
65
+ /** `"none"` selects the streams that have no period at all. */
66
+ interval?: Filter;
67
+ }
68
+
69
+ export interface MetaResult {
70
+ firstDay?: string;
71
+ lastDay?: string;
72
+ /** Number of days in scope, not a span. */
73
+ days: number;
74
+ venues: string[];
75
+ assets: string[];
76
+ /** Real durations only — the sentinel lives in `filterTokens`. */
77
+ intervals: string[];
78
+ /** dataset name -> human description. */
79
+ datasets: Record<string, string>;
80
+ filterTokens: {
81
+ noValue: 'none';
82
+ /** Which dimensions some file leaves empty. */
83
+ appliesTo: string[];
84
+ };
85
+ scope?: unknown;
86
+ sampledFrom?: string;
87
+ }
88
+
89
+ export interface DaysResult {
90
+ days: string[];
91
+ /** Earliest downloadable day, or null when unbounded. */
92
+ floor: string | null;
93
+ /** Latest downloadable day, or null when unbounded. */
94
+ ceiling: string | null;
95
+ scope?: unknown;
96
+ }
97
+
98
+ export interface SignedUrl {
99
+ url: string;
100
+ name: string;
101
+ bytes: number;
102
+ sha256: string;
103
+ expiresInSec: number;
104
+ }
105
+
106
+ export interface DownloadResult {
107
+ bytes: Uint8Array;
108
+ /** The checksum that was verified, when one was available. */
109
+ sha256: string | null;
110
+ name: string;
111
+ date: string;
112
+ }
113
+
114
+ export interface DownloadOptions {
115
+ /** Verify the sha256. Default true. */
116
+ verify?: boolean;
117
+ /** Also write the bytes to this path. */
118
+ saveTo?: string;
119
+ }
120
+
121
+ export interface DataClientOptions {
122
+ /** Defaults to process.env.OT_KEY. */
123
+ key?: string | null;
124
+ /** Defaults to https://outcometick.com. */
125
+ baseUrl?: string;
126
+ /** Injectable for tests. */
127
+ fetch?: typeof globalThis.fetch;
128
+ }
129
+
130
+ export declare class DataClient {
131
+ constructor(options?: DataClientOptions);
132
+ readonly key: string | null;
133
+ readonly baseUrl: string;
134
+
135
+ /** The date window and every dimension value in it. */
136
+ meta(): Promise<MetaResult>;
137
+
138
+ /** The days this key may download, with the window bounds. */
139
+ days(): Promise<DaysResult>;
140
+
141
+ /** Search across a date range. Filters accept a string or an array. */
142
+ files(query?: FilesQuery): Promise<FilesResult>;
143
+
144
+ /** A presigned URL, without fetching the bytes. Short-lived. */
145
+ signUrl(date: string, name: string, options?: { expiresIn?: number }): Promise<SignedUrl>;
146
+
147
+ /** Download one file, verifying its checksum. */
148
+ download(file: FileRow, options?: DownloadOptions): Promise<DownloadResult>;
149
+ download(date: string, name: string, options?: DownloadOptions): Promise<DownloadResult>;
150
+
151
+ /** Public — no key required. */
152
+ coverage(): Promise<unknown>;
153
+ plans(): Promise<unknown>;
154
+ health(): Promise<unknown>;
155
+ }
156
+
157
+ export default DataClient;
@@ -0,0 +1,263 @@
1
+ // The data-subscription client: `outcometick/data`.
2
+ //
3
+ // import { DataClient } from "outcometick/data";
4
+ // const ot = new DataClient(); // key from OT_KEY
5
+ // const { files } = await ot.files({ asset: ["btc", "eth"], dataset: "prices" });
6
+ // await ot.download(files[0], "./btc.csv.gz"); // verifies the checksum
7
+ //
8
+ // Deliberately a SUBPATH, not part of the package root. The root exports the
9
+ // strategy SDK — `Strategy` and `Order` — which is what a backtest imports, and
10
+ // that code runs in a container with no network at all. Putting an HTTP client
11
+ // on the same import would invite a strategy to reach for it, type-check
12
+ // locally, and then fail inside the sandbox. Here it cannot be reached by
13
+ // accident, and the submission analyser rejects the import outright.
14
+ //
15
+ // Everything this file knows about the API's shape came from reading
16
+ // api/subscription-api.mjs, not from the docs page. The two had drifted: the
17
+ // published curl example shows only `asset` and `dataset`, while /v1/files also
18
+ // takes a date RANGE and a venue and an interval, and every filter accepts
19
+ // comma-separated alternatives plus a `none` sentinel.
20
+
21
+ import { createHash } from 'node:crypto';
22
+ import { writeFile } from 'node:fs/promises';
23
+
24
+ export const DEFAULT_BASE_URL = 'https://outcometick.com';
25
+
26
+ /** The sentinel that names files with no value for a dimension. */
27
+ export const NO_VALUE = 'none';
28
+
29
+ /**
30
+ * An API error, carrying whatever the server said alongside the status.
31
+ *
32
+ * The subscription API answers 403 on a date outside coverage with the actual
33
+ * `floor` and `ceiling`, which is the difference between "you cannot have this"
34
+ * and "you cannot have this, here is what you can have". Flattening that into a
35
+ * message string would throw the useful half away.
36
+ */
37
+ export class OutcometickError extends Error {
38
+ constructor(status, body, url) {
39
+ const detail = body?.error ?? (typeof body === 'string' ? body.slice(0, 200) : 'request failed');
40
+ super(`${status} ${detail}`);
41
+ this.name = 'OutcometickError';
42
+ this.status = status;
43
+ this.detail = detail;
44
+ this.body = body;
45
+ this.url = url;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Render one filter value.
51
+ *
52
+ * Arrays join with commas because that is exactly what the API means by
53
+ * `asset=btc,eth` — alternatives, not a nested structure. Passing an array is
54
+ * the friendlier spelling of the same request, so both work.
55
+ */
56
+ function filterValue(v) {
57
+ if (v == null) return null;
58
+ const parts = (Array.isArray(v) ? v : [v])
59
+ .map((x) => String(x).trim())
60
+ .filter(Boolean);
61
+ return parts.length ? parts.join(',') : null;
62
+ }
63
+
64
+ export class DataClient {
65
+ /**
66
+ * @param opts.key API key. Defaults to process.env.OT_KEY.
67
+ * @param opts.baseUrl API origin. Defaults to https://outcometick.com.
68
+ * @param opts.fetch Injectable for tests.
69
+ */
70
+ constructor({ key = null, baseUrl = DEFAULT_BASE_URL, fetch: fetchImpl = null } = {}) {
71
+ // Read at construction so the failure is "you have not set a key", raised
72
+ // once and early, rather than a 401 from whichever call happened to be
73
+ // first.
74
+ this.key = key ?? process.env.OT_KEY ?? null;
75
+ this.baseUrl = String(baseUrl).replace(/\/+$/, '');
76
+ this._fetch = fetchImpl ?? globalThis.fetch;
77
+ }
78
+
79
+ /** The key, or a readable explanation of its absence. */
80
+ _requireKey() {
81
+ if (!this.key) {
82
+ throw new Error('no API key.\n'
83
+ + ' Pass one as new DataClient({ key }), or set OT_KEY:\n'
84
+ + ' export OT_KEY="ck_…"');
85
+ }
86
+ return this.key;
87
+ }
88
+
89
+ async _get(path, { query = null, auth = true, redirect = 'follow' } = {}) {
90
+ const url = new URL(this.baseUrl + path);
91
+ for (const [k, v] of Object.entries(query ?? {})) {
92
+ const value = filterValue(v);
93
+ if (value !== null) url.searchParams.set(k, value);
94
+ }
95
+ const headers = auth ? { authorization: `Bearer ${this._requireKey()}` } : {};
96
+
97
+ let res;
98
+ try {
99
+ res = await this._fetch(url, { headers, redirect });
100
+ } catch (err) {
101
+ throw new Error(`could not reach ${this.baseUrl}: ${err.message}`);
102
+ }
103
+ return { res, url: url.toString() };
104
+ }
105
+
106
+ async _json(path, opts = {}) {
107
+ const { res, url } = await this._get(path, opts);
108
+ const text = await res.text();
109
+ let body;
110
+ try { body = JSON.parse(text); } catch { body = text; }
111
+ if (!res.ok) throw new OutcometickError(res.status, body, url);
112
+ return body;
113
+ }
114
+
115
+ // ---------- discovery -------------------------------------------------
116
+
117
+ /**
118
+ * What this key can see: the date window, and every dimension value in it.
119
+ *
120
+ * `assets` and `intervals` hold real symbols and real durations only. The
121
+ * `none` sentinel is reported separately under `filterTokens`, because a
122
+ * client that builds an enum from `intervals` or parses them as durations
123
+ * must not meet a token.
124
+ */
125
+ async meta() {
126
+ return this._json('/v1/meta');
127
+ }
128
+
129
+ /** The days this key may download, with the window's floor and ceiling. */
130
+ async days() {
131
+ return this._json('/v1/mirror/days');
132
+ }
133
+
134
+ /**
135
+ * Search for files across a date range.
136
+ *
137
+ * @param q.date one day — sugar for from === to. Cannot be combined
138
+ * with from/to.
139
+ * @param q.from inclusive start; defaults to the newest day in scope.
140
+ * @param q.to inclusive end; defaults to `from`.
141
+ * @param q.venue polymarket | predict-fun
142
+ * @param q.dataset prices | twap60s | book | klines | … (see meta())
143
+ * @param q.asset BTCUSD, ETHUSD, …
144
+ * @param q.interval 5m, 1h, … or "none" for the streams that have no period
145
+ *
146
+ * Every filter accepts a string or an array; an array is joined with commas
147
+ * and means "any of these". `interval: ["5m", NO_VALUE]` is how you ask for
148
+ * 5-minute files AND the period-less settlement streams — asking for "5m"
149
+ * alone deliberately excludes them.
150
+ *
151
+ * The server caps the range (92 days by default) and answers 400 past it.
152
+ */
153
+ async files(q = {}) {
154
+ if (q.date && (q.from || q.to)) {
155
+ // The server rejects this too; catching it here saves a round trip and
156
+ // says the same thing, so the two cannot describe it differently.
157
+ throw new Error('use either date, or from/to — not both');
158
+ }
159
+ return this._json('/v1/files', {
160
+ query: {
161
+ date: q.date, from: q.from, to: q.to,
162
+ venue: q.venue, dataset: q.dataset, asset: q.asset, interval: q.interval,
163
+ },
164
+ });
165
+ }
166
+
167
+ // ---------- download --------------------------------------------------
168
+
169
+ /**
170
+ * A presigned URL for one file, without fetching it.
171
+ *
172
+ * Useful when something else does the fetching — a data frame library, a job
173
+ * runner, a browser. The URL is short-lived; hold the `{date, name}` pair and
174
+ * ask again rather than storing it.
175
+ */
176
+ async signUrl(date, name, { expiresIn = null } = {}) {
177
+ return this._json('/v1/mirror/download', {
178
+ query: { date, name, ...(expiresIn ? { expiresIn } : {}) },
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Download one file.
184
+ *
185
+ * Accepts either a row from files() or an explicit (date, name).
186
+ *
187
+ * The checksum is verified by default. /v1/dl answers 302 with the sha256 in
188
+ * a header and the bytes come from R2 behind the redirect, so the redirect is
189
+ * followed MANUALLY: letting fetch follow it would discard the header and
190
+ * with it the only checksum available without a second API call. A row from
191
+ * files() carries its own sha256, which is used when present.
192
+ *
193
+ * @returns {Promise<{bytes: Uint8Array, sha256: string|null, name: string, date: string}>}
194
+ */
195
+ async download(fileOrDate, nameOrOpts = null, maybeOpts = null) {
196
+ const isRow = fileOrDate && typeof fileOrDate === 'object';
197
+ const date = isRow ? fileOrDate.date : fileOrDate;
198
+ const name = isRow ? fileOrDate.name : nameOrOpts;
199
+ const opts = (isRow ? nameOrOpts : maybeOpts) ?? {};
200
+ const { verify = true, saveTo = null } = opts;
201
+
202
+ if (!date || !name) throw new Error('download needs a file row, or a date and a name');
203
+
204
+ const { res, url } = await this._get(
205
+ `/v1/dl/${encodeURIComponent(date)}/${encodeURIComponent(name)}`,
206
+ { redirect: 'manual' },
207
+ );
208
+
209
+ let expected = isRow ? (fileOrDate.sha256 ?? null) : null;
210
+ let bytesRes = res;
211
+
212
+ if (res.status >= 300 && res.status < 400) {
213
+ expected = expected
214
+ ?? res.headers.get('x-outcometick-sha256')
215
+ ?? res.headers.get('x-amz-meta-sha256')
216
+ ?? null;
217
+ const location = res.headers.get('location');
218
+ if (!location) throw new OutcometickError(res.status, { error: 'redirect with no location' }, url);
219
+ // The signed URL carries its own auth; sending ours to R2 as well would
220
+ // leak the key to a host that has no use for it.
221
+ bytesRes = await this._fetch(location, { redirect: 'follow' });
222
+ }
223
+
224
+ if (!bytesRes.ok) {
225
+ const text = await bytesRes.text();
226
+ let body;
227
+ try { body = JSON.parse(text); } catch { body = text; }
228
+ throw new OutcometickError(bytesRes.status, body, url);
229
+ }
230
+
231
+ const bytes = new Uint8Array(await bytesRes.arrayBuffer());
232
+
233
+ if (verify && expected) {
234
+ const got = createHash('sha256').update(bytes).digest('hex');
235
+ if (got !== expected) {
236
+ throw new Error(`checksum mismatch for ${date}/${name}\n`
237
+ + ` expected ${expected}\n got ${got}`);
238
+ }
239
+ }
240
+
241
+ if (saveTo) await writeFile(saveTo, bytes);
242
+ return { bytes, sha256: expected, name, date };
243
+ }
244
+
245
+ // ---------- public, no key needed -------------------------------------
246
+
247
+ /** Coverage across all venues. Public — works without a key. */
248
+ async coverage() {
249
+ return this._json('/v1/public/coverage', { auth: false });
250
+ }
251
+
252
+ /** Plans and live prices. Public. */
253
+ async plans() {
254
+ return this._json('/v1/public/plans', { auth: false });
255
+ }
256
+
257
+ /** Liveness. Public. */
258
+ async health() {
259
+ return this._json('/v1/health', { auth: false });
260
+ }
261
+ }
262
+
263
+ export default DataClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "outcometick",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Strategy SDK and CLI for outcometick prediction-market backtests",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -31,11 +31,12 @@
31
31
  },
32
32
  "files": [
33
33
  "api",
34
- "runner",
35
- "cli",
36
34
  "bin",
37
- "index.mjs",
35
+ "cli",
36
+ "client",
38
37
  "index.d.ts",
38
+ "index.mjs",
39
+ "runner",
39
40
  "!**/*.test.mjs",
40
41
  "!**/*.test-d.ts"
41
42
  ],