@forgezero/runtime 0.1.13 → 0.1.15

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.
@@ -1,313 +0,0 @@
1
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
- }) : x)(function(x) {
4
- if (typeof require !== "undefined")
5
- return require.apply(this, arguments);
6
- throw Error('Dynamic require of "' + x + '" is not supported');
7
- });
8
-
9
- // src/finance/money.ts
10
- class MoneyError extends Error {
11
- code;
12
- constructor(code, message) {
13
- super(message);
14
- this.code = code;
15
- this.name = "MoneyError";
16
- }
17
- }
18
- var ASSETS = [
19
- { code: "USDT", decimals: 6 },
20
- { code: "USDC", decimals: 6 },
21
- { code: "BTC", decimals: 8 },
22
- { code: "ETH", decimals: 18 },
23
- { code: "BNB", decimals: 18 },
24
- { code: "EUR", decimals: 2 },
25
- { code: "USD", decimals: 2 }
26
- ];
27
- var REGISTRY = new Map(ASSETS.map((asset) => [asset.code, asset]));
28
- function defineAsset(spec) {
29
- if (spec.decimals < 0 || spec.decimals > 30 || !Number.isInteger(spec.decimals)) {
30
- throw new MoneyError("UNKNOWN_ASSET", `${spec.code}: decimals must be an integer 0–30.`);
31
- }
32
- REGISTRY.set(spec.code, spec);
33
- }
34
- function assetSpec(code) {
35
- const spec = REGISTRY.get(code);
36
- if (!spec)
37
- throw new MoneyError("UNKNOWN_ASSET", `Unknown asset "${code}". Call defineAsset first.`);
38
- return spec;
39
- }
40
- var money = (units, asset) => {
41
- assetSpec(asset);
42
- return { units, asset };
43
- };
44
- var zero = (asset) => money(0n, asset);
45
- function parseAmount(value, asset) {
46
- const spec = assetSpec(asset);
47
- const text = value.trim();
48
- if (!/^-?\d+(\.\d+)?$/.test(text)) {
49
- throw new MoneyError("NOT_FINITE", `"${value}" is not a plain decimal amount.`);
50
- }
51
- const negative = text.startsWith("-");
52
- const [whole, fraction = ""] = text.replace("-", "").split(".");
53
- if (fraction.length > spec.decimals) {
54
- throw new MoneyError("PRECISION_LOSS", `${asset} has ${spec.decimals} decimals; "${value}" has ${fraction.length}.`);
55
- }
56
- const padded = fraction.padEnd(spec.decimals, "0");
57
- const units = BigInt(whole + padded);
58
- return { units: negative ? -units : units, asset };
59
- }
60
- function formatAmount(amount, options = {}) {
61
- const spec = assetSpec(amount.asset);
62
- const negative = amount.units < 0n;
63
- const digits = (negative ? -amount.units : amount.units).toString().padStart(spec.decimals + 1, "0");
64
- const whole = digits.slice(0, digits.length - spec.decimals);
65
- let fraction = spec.decimals === 0 ? "" : digits.slice(digits.length - spec.decimals);
66
- if (options.trim && fraction)
67
- fraction = fraction.replace(/0+$/, "");
68
- return `${negative ? "-" : ""}${whole}${fraction ? `.${fraction}` : ""}`;
69
- }
70
- function sameAsset(a, b) {
71
- if (a.asset !== b.asset) {
72
- throw new MoneyError("ASSET_MISMATCH", `Cannot combine ${a.asset} and ${b.asset}.`);
73
- }
74
- }
75
- function add(a, b) {
76
- sameAsset(a, b);
77
- return { units: a.units + b.units, asset: a.asset };
78
- }
79
- function subtract(a, b) {
80
- sameAsset(a, b);
81
- return { units: a.units - b.units, asset: a.asset };
82
- }
83
- var negate = (amount) => ({ units: -amount.units, asset: amount.asset });
84
- var abs = (amount) => ({
85
- units: amount.units < 0n ? -amount.units : amount.units,
86
- asset: amount.asset
87
- });
88
- var isZero = (amount) => amount.units === 0n;
89
- var isNegative = (amount) => amount.units < 0n;
90
- function compare(a, b) {
91
- sameAsset(a, b);
92
- return a.units < b.units ? -1 : a.units > b.units ? 1 : 0;
93
- }
94
- var equals = (a, b) => a.asset === b.asset && a.units === b.units;
95
- var ROUNDING = ["down", "up", "half-up"];
96
- function divideRounded(numerator, denominator, mode) {
97
- if (denominator === 0n)
98
- throw new MoneyError("DIVIDE_BY_ZERO", "Division by zero.");
99
- const negative = numerator < 0n !== denominator < 0n;
100
- const a = numerator < 0n ? -numerator : numerator;
101
- const b = denominator < 0n ? -denominator : denominator;
102
- const quotient = a / b;
103
- const remainder = a % b;
104
- if (remainder === 0n)
105
- return negative ? -quotient : quotient;
106
- let result = quotient;
107
- if (mode === "up")
108
- result += 1n;
109
- else if (mode === "half-up" && remainder * 2n >= b)
110
- result += 1n;
111
- return negative ? -result : result;
112
- }
113
- function mulRate(amount, rate, mode = "down") {
114
- if (!/^-?\d+(\.\d+)?$/.test(rate.trim())) {
115
- throw new MoneyError("NOT_FINITE", `"${rate}" is not a plain decimal rate.`);
116
- }
117
- const [whole, fraction = ""] = rate.trim().replace("-", "").split(".");
118
- const scale = 10n ** BigInt(fraction.length);
119
- const scaled = BigInt(whole + fraction) * (rate.trim().startsWith("-") ? -1n : 1n);
120
- return { units: divideRounded(amount.units * scaled, scale, mode), asset: amount.asset };
121
- }
122
- function convert(amount, to, rate, mode = "down") {
123
- const from = assetSpec(amount.asset);
124
- const target = assetSpec(to);
125
- const asTarget = mulRate({ units: amount.units, asset: to }, rate, mode);
126
- const shift = target.decimals - from.decimals;
127
- if (shift === 0)
128
- return asTarget;
129
- if (shift > 0)
130
- return { units: asTarget.units * 10n ** BigInt(shift), asset: to };
131
- return { units: divideRounded(asTarget.units, 10n ** BigInt(-shift), mode), asset: to };
132
- }
133
- function allocate(amount, parts) {
134
- if (parts < 1)
135
- throw new MoneyError("NOT_FINITE", "Cannot allocate into fewer than one part.");
136
- const each = divideRounded(amount.units, BigInt(parts), "down");
137
- const allocated = Array.from({ length: parts }, () => each);
138
- let remainder = amount.units - each * BigInt(parts);
139
- const step = remainder < 0n ? -1n : 1n;
140
- for (let index = 0;remainder !== 0n; index = (index + 1) % parts) {
141
- allocated[index] += step;
142
- remainder -= step;
143
- }
144
- return allocated.map((units) => ({ units, asset: amount.asset }));
145
- }
146
- function toStep(amount, step, mode = "down") {
147
- const stepUnits = parseAmount(step, amount.asset).units;
148
- if (stepUnits <= 0n)
149
- throw new MoneyError("NOT_FINITE", "A step must be positive.");
150
- return { units: divideRounded(amount.units, stepUnits, mode) * stepUnits, asset: amount.asset };
151
- }
152
- var VERSION = "0.1.0";
153
-
154
- // src/finance/ledger.ts
155
- class LedgerError extends Error {
156
- code;
157
- constructor(code, message) {
158
- super(message);
159
- this.code = code;
160
- this.name = "LedgerError";
161
- }
162
- }
163
- var BUCKETS = ["available", "held"];
164
- var ACCOUNT_KINDS = ["user", "platform", "external"];
165
- var accountId = (account) => `${account.kind}:${account.owner}:${account.bucket ?? "available"}`;
166
- function parseAccount(id) {
167
- const [kind, owner, bucket] = id.split(":");
168
- return { kind, owner, bucket };
169
- }
170
- var queueKeyFor = (account) => `${account.kind}:${account.owner}`;
171
- var MAX_LEDGER_ENTRIES = 64;
172
- function assertBalanced(transaction) {
173
- if (transaction.entries.length < 2) {
174
- throw new LedgerError("EMPTY_TRANSACTION", "A transaction needs at least two entries.");
175
- }
176
- if (transaction.entries.length > MAX_LEDGER_ENTRIES) {
177
- throw new LedgerError("TOO_MANY_ENTRIES", `A transaction may contain at most ${MAX_LEDGER_ENTRIES} entries; split this batch into separate transactions.`);
178
- }
179
- const totals = new Map;
180
- for (const entry of transaction.entries) {
181
- if (entry.amount.units === 0n) {
182
- throw new LedgerError("ZERO_ENTRY", `An entry on ${accountId(entry.account)} moves nothing. Remove it or give it an amount.`);
183
- }
184
- totals.set(entry.amount.asset, (totals.get(entry.amount.asset) ?? 0n) + entry.amount.units);
185
- }
186
- for (const [asset, total] of totals) {
187
- if (total !== 0n) {
188
- throw new LedgerError("UNBALANCED", `${asset} does not sum to zero — it is off by ${total} minor units. Every movement needs a counterparty.`);
189
- }
190
- }
191
- }
192
- function transfer(args) {
193
- const transaction = {
194
- reference: args.reference,
195
- kind: args.kind,
196
- atMs: args.atMs,
197
- memo: args.memo,
198
- entries: [
199
- { account: args.from, amount: { units: -args.amount.units, asset: args.amount.asset } },
200
- { account: args.to, amount: args.amount }
201
- ]
202
- };
203
- assertBalanced(transaction);
204
- return transaction;
205
- }
206
- var placeHold = (args) => transfer({
207
- reference: args.reference,
208
- kind: "hold.place",
209
- from: { kind: args.kind ?? "user", owner: args.owner, bucket: "available" },
210
- to: { kind: args.kind ?? "user", owner: args.owner, bucket: "held" },
211
- amount: args.amount,
212
- atMs: args.atMs,
213
- memo: args.memo
214
- });
215
- var releaseHold = (args) => transfer({
216
- reference: args.reference,
217
- kind: "hold.release",
218
- from: { kind: args.kind ?? "user", owner: args.owner, bucket: "held" },
219
- to: { kind: args.kind ?? "user", owner: args.owner, bucket: "available" },
220
- amount: args.amount,
221
- atMs: args.atMs
222
- });
223
- var captureHold = (args) => transfer({
224
- reference: args.reference,
225
- kind: "hold.capture",
226
- from: { kind: args.kind ?? "user", owner: args.owner, bucket: "held" },
227
- to: args.to,
228
- amount: args.amount,
229
- atMs: args.atMs,
230
- memo: args.memo
231
- });
232
- function balancesFrom(transactions) {
233
- const balances = new Map;
234
- for (const transaction of transactions) {
235
- for (const entry of transaction.entries) {
236
- const id = accountId(entry.account);
237
- const perAsset = balances.get(id) ?? new Map;
238
- const current = perAsset.get(entry.amount.asset) ?? zero(entry.amount.asset);
239
- perAsset.set(entry.amount.asset, add(current, entry.amount));
240
- balances.set(id, perAsset);
241
- }
242
- }
243
- return balances;
244
- }
245
- function balanceOf(balances, account, asset) {
246
- return balances.get(accountId(account))?.get(asset) ?? zero(asset);
247
- }
248
- var availableOf = (balances, owner, asset, kind = "user") => balanceOf(balances, { kind, owner, bucket: "available" }, asset);
249
- var heldOf = (balances, owner, asset, kind = "user") => balanceOf(balances, { kind, owner, bucket: "held" }, asset);
250
- var totalOf = (balances, owner, asset, kind = "user") => add(availableOf(balances, owner, asset, kind), heldOf(balances, owner, asset, kind));
251
- function trialBalance(transactions) {
252
- const totals = new Map;
253
- const accounts = new Map;
254
- for (const transaction of transactions) {
255
- for (const entry of transaction.entries) {
256
- totals.set(entry.amount.asset, (totals.get(entry.amount.asset) ?? 0n) + entry.amount.units);
257
- const seen = accounts.get(entry.amount.asset) ?? new Set;
258
- seen.add(accountId(entry.account));
259
- accounts.set(entry.amount.asset, seen);
260
- }
261
- }
262
- const perAsset = [...totals.entries()].map(([asset, total]) => ({
263
- asset,
264
- total,
265
- accounts: accounts.get(asset)?.size ?? 0
266
- }));
267
- const discrepancies = perAsset.filter((row) => row.total !== 0n).map((row) => ({ asset: row.asset, off: row.total }));
268
- return { ok: discrepancies.length === 0, perAsset, discrepancies };
269
- }
270
- function statement(transactions, account, asset) {
271
- const id = accountId(account);
272
- let running = zero(asset);
273
- return [...transactions].sort((a, b) => a.atMs - b.atMs).flatMap((transaction) => transaction.entries.filter((entry) => accountId(entry.account) === id && entry.amount.asset === asset).map((entry) => {
274
- running = add(running, entry.amount);
275
- return {
276
- atMs: transaction.atMs,
277
- reference: transaction.reference,
278
- kind: transaction.kind,
279
- amount: formatAmount(entry.amount, { trim: true }),
280
- balance: formatAmount(running, { trim: true })
281
- };
282
- }));
283
- }
284
- function assertAvailable(balances, args) {
285
- const available = availableOf(balances, args.owner, args.amount.asset, args.kind ?? "user");
286
- if (subtract(available, args.amount).units < 0n) {
287
- throw new LedgerError("INSUFFICIENT_AVAILABLE", `${args.owner} has ${formatAmount(available, { trim: true })} ${args.amount.asset} available; this needs ${formatAmount(args.amount, { trim: true })}.`);
288
- }
289
- }
290
- var VERSION2 = "0.1.0";
291
- export {
292
- trialBalance,
293
- transfer,
294
- totalOf,
295
- statement,
296
- releaseHold,
297
- queueKeyFor,
298
- placeHold,
299
- parseAccount,
300
- heldOf,
301
- captureHold,
302
- balancesFrom,
303
- balanceOf,
304
- availableOf,
305
- assertBalanced,
306
- assertAvailable,
307
- accountId,
308
- VERSION2 as VERSION,
309
- MAX_LEDGER_ENTRIES,
310
- LedgerError,
311
- BUCKETS,
312
- ACCOUNT_KINDS
313
- };
@@ -1,209 +0,0 @@
1
- import { type Money } from './money';
2
- /**
3
- * Prices from several venues, with staleness stated rather than assumed.
4
- *
5
- * The thing this exists to prevent is two projects each growing their own price
6
- * client with their own private opinion about what "current" means. That
7
- * disagreement is invisible — both return a number, both look right — and it
8
- * surfaces as one service valuing a position at a price another service already
9
- * considered dead.
10
- *
11
- * So the model here is small and explicit:
12
- *
13
- * A price is a VALUE AND AN AGE. Never a value alone.
14
- * Age is measured against a clock the CALLER passes in, not `Date.now()`.
15
- * Past a stated limit, a price is not returned — it is refused.
16
- *
17
- * ## Refusing beats returning a stale number
18
- *
19
- * The tempting design returns the last known price with a flag, and every
20
- * caller is expected to check it. They do not. A liquidation priced off a
21
- * twenty-minute-old book is the loss this prevents, and it is prevented by
22
- * making the stale case impossible to consume by accident rather than by
23
- * documenting it.
24
- *
25
- * ## Why no HTTP here
26
- *
27
- * `@forgezero/runtime` holds no credentials and no network, and it may not
28
- * import `@forgezero/providers` — that direction is enforced, because pricing
29
- * that depends on a provider registry cannot be used by the provider registry.
30
- * A venue is injected as a function returning quotes. The same feed therefore
31
- * runs against a live socket, a recorded session, or a fixture, and the tests
32
- * exercise real staleness and disagreement rather than a mock of them.
33
- */
34
- export declare class MarketError extends Error {
35
- readonly code: 'STALE' | 'NO_PRICE' | 'UNKNOWN_SYMBOL' | 'MALFORMED';
36
- /**
37
- * The measurements behind the refusal.
38
- *
39
- * Carried on the error because a subscriber turning a refusal into an
40
- * event needs the numbers, and recomputing them from outside would mean a
41
- * second implementation of the check that just ran — free to disagree
42
- * with the first.
43
- */
44
- readonly detail: {
45
- youngestAgeMs?: number;
46
- spread?: number;
47
- };
48
- constructor(code: 'STALE' | 'NO_PRICE' | 'UNKNOWN_SYMBOL' | 'MALFORMED', message: string,
49
- /**
50
- * The measurements behind the refusal.
51
- *
52
- * Carried on the error because a subscriber turning a refusal into an
53
- * event needs the numbers, and recomputing them from outside would mean a
54
- * second implementation of the check that just ran — free to disagree
55
- * with the first.
56
- */
57
- detail?: {
58
- youngestAgeMs?: number;
59
- spread?: number;
60
- });
61
- }
62
- /** One venue's view of one symbol at one instant. */
63
- export interface Quote {
64
- venue: string;
65
- symbol: string;
66
- /** Mid, or last trade — the venue decides, and says so in `kind`. */
67
- price: Money;
68
- kind: 'trade' | 'mid' | 'index';
69
- /** When the VENUE says this was true, not when we received it. */
70
- atMs: number;
71
- /** Best bid and ask, when the venue publishes a book. */
72
- bid?: Money;
73
- ask?: Money;
74
- }
75
- /**
76
- * How old a price may be before it stops being a price.
77
- *
78
- * Per symbol class rather than one global number, because the right staleness
79
- * for a major pair is not the right staleness for something that trades twice
80
- * an hour — a single limit either rejects every quote on the illiquid one or
81
- * accepts a dead quote on the liquid one.
82
- */
83
- export interface StalenessPolicy {
84
- /** Applies to anything without a more specific rule. */
85
- defaultMs: number;
86
- /** Keyed by symbol. Overrides the default exactly. */
87
- bySymbol?: Readonly<Record<string, number>>;
88
- }
89
- export declare const limitFor: (policy: StalenessPolicy, symbol: string) => number;
90
- /**
91
- * How old this quote is, in milliseconds.
92
- *
93
- * Negative when a venue's clock runs ahead of ours — reported rather than
94
- * clamped to zero. A venue consistently ahead is a real condition worth seeing,
95
- * and clamping makes it look like a fresh quote forever.
96
- */
97
- export declare const stalenessOf: (quote: Quote, nowMs: number) => number;
98
- export declare const isStale: (quote: Quote, policy: StalenessPolicy, nowMs: number) => boolean;
99
- /** A venue, reduced to the one thing this module needs from it. */
100
- export interface VenueSource {
101
- venue: string;
102
- /** Latest known quote, or null when this venue has never seen the symbol. */
103
- latest(symbol: string): Quote | null;
104
- }
105
- export interface FeedOptions {
106
- sources: readonly VenueSource[];
107
- policy: StalenessPolicy;
108
- /**
109
- * Venue preference, most trusted first.
110
- *
111
- * Not a fallback list — every fresh quote is considered, and this only breaks
112
- * a tie. A strict fallback order means the second venue is consulted only
113
- * when the first is silent, which hides a first venue quoting nonsense.
114
- */
115
- prefer?: readonly string[];
116
- /**
117
- * How far two venues may disagree before the feed refuses to pick, as a
118
- * fraction — `0.05` is five per cent.
119
- *
120
- * Disagreement past this is not a price to choose between; it is a signal
121
- * that one venue is broken, and choosing either is how a bad print becomes a
122
- * filled order. Undefined means never refuse on disagreement.
123
- */
124
- maxDisagreement?: number;
125
- }
126
- export interface PriceResult {
127
- quote: Quote;
128
- ageMs: number;
129
- /** Every fresh quote considered, including the one chosen. */
130
- considered: readonly Quote[];
131
- /** Largest fractional gap among those, or 0 when only one venue had a price. */
132
- spread: number;
133
- }
134
- /**
135
- * The widest fractional gap between any two of these quotes.
136
- *
137
- * Measured against the SMALLER of each pair, so the figure is what it would
138
- * cost to be on the wrong side — dividing by the larger understates exactly the
139
- * case that matters.
140
- */
141
- export declare function disagreementOf(quotes: readonly Quote[]): number;
142
- /**
143
- * A feed over several venues.
144
- *
145
- * Holds no state and starts nothing. The sources are already-connected things a
146
- * caller owns; this decides what to believe from them, which is the part that
147
- * two projects would otherwise each get slightly differently.
148
- */
149
- export declare function createFeed(options: FeedOptions): {
150
- /** Every venue's current view, fresh or not. For an operator screen. */
151
- snapshot(symbol: string, nowMs: number): readonly (Quote & {
152
- ageMs: number;
153
- stale: boolean;
154
- })[];
155
- /**
156
- * The price, or an error explaining which of the two ways it failed.
157
- *
158
- * "No venue has ever seen this symbol" and "every venue's price is too old"
159
- * are different problems with different fixes — a missing subscription
160
- * versus a dead connection — and one error covering both sends whoever is
161
- * paged to the wrong place.
162
- */
163
- price(symbol: string, nowMs: number): PriceResult;
164
- };
165
- export type Feed = ReturnType<typeof createFeed>;
166
- /**
167
- * The price alone, for callers that only want the number.
168
- *
169
- * Still throws on stale — the convenience is in the return type, never in the
170
- * checking. A helper that returned a possibly-stale number would reintroduce
171
- * exactly the failure this module exists to remove.
172
- */
173
- export declare const lastPrice: (feed: Feed, symbol: string, nowMs: number) => Money;
174
- /** A change worth telling a subscriber about. */
175
- export type FeedEvent = {
176
- type: 'price';
177
- symbol: string;
178
- result: PriceResult;
179
- } | {
180
- type: 'stale';
181
- symbol: string;
182
- youngestAgeMs: number;
183
- } | {
184
- type: 'disagreement';
185
- symbol: string;
186
- spread: number;
187
- };
188
- /**
189
- * Watch a set of symbols, and be told when the ANSWER changes.
190
- *
191
- * Deliberately not "be told when a quote arrives". A busy venue publishes many
192
- * times a second and almost none of it changes what the feed would answer, so
193
- * forwarding every tick makes a subscriber's own throttling load-bearing — and
194
- * whichever project writes that throttle badly gets a different price from the
195
- * one that writes it well.
196
- *
197
- * Transitions into and out of staleness are events in their own right. A
198
- * subscriber that only hears about prices cannot tell a quiet market from a
199
- * dead connection, which is the distinction the whole module is built around.
200
- */
201
- export declare function subscribe(feed: Feed, args: {
202
- symbols: readonly string[];
203
- onEvent: (event: FeedEvent) => void;
204
- /** Injected, so a test drives time instead of waiting for it. */
205
- now: () => number;
206
- }): {
207
- poll: () => void;
208
- seen: () => ReadonlyMap<string, string>;
209
- };
@@ -1,112 +0,0 @@
1
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
- }) : x)(function(x) {
4
- if (typeof require !== "undefined")
5
- return require.apply(this, arguments);
6
- throw Error('Dynamic require of "' + x + '" is not supported');
7
- });
8
-
9
- // src/finance/market.ts
10
- class MarketError extends Error {
11
- code;
12
- detail;
13
- constructor(code, message, detail = {}) {
14
- super(message);
15
- this.code = code;
16
- this.detail = detail;
17
- this.name = "MarketError";
18
- }
19
- }
20
- var limitFor = (policy, symbol) => policy.bySymbol?.[symbol] ?? policy.defaultMs;
21
- var stalenessOf = (quote, nowMs) => nowMs - quote.atMs;
22
- var isStale = (quote, policy, nowMs) => stalenessOf(quote, nowMs) > limitFor(policy, quote.symbol);
23
- var asNumber = (amount) => Number(amount.units);
24
- function disagreementOf(quotes) {
25
- if (quotes.length < 2)
26
- return 0;
27
- const values = quotes.map((quote) => asNumber(quote.price)).filter((value) => value > 0);
28
- if (values.length < 2)
29
- return 0;
30
- const low = Math.min(...values);
31
- const high = Math.max(...values);
32
- return (high - low) / low;
33
- }
34
- function createFeed(options) {
35
- if (options.sources.length === 0) {
36
- throw new MarketError("MALFORMED", "A feed needs at least one venue.");
37
- }
38
- if (options.policy.defaultMs <= 0) {
39
- throw new MarketError("MALFORMED", "A staleness limit is a positive number of milliseconds.");
40
- }
41
- const rank = (venue) => {
42
- const at = options.prefer?.indexOf(venue) ?? -1;
43
- return at === -1 ? Number.MAX_SAFE_INTEGER : at;
44
- };
45
- return {
46
- snapshot(symbol, nowMs) {
47
- return options.sources.map((source) => source.latest(symbol)).filter((quote) => quote !== null).map((quote) => ({
48
- ...quote,
49
- ageMs: stalenessOf(quote, nowMs),
50
- stale: isStale(quote, options.policy, nowMs)
51
- }));
52
- },
53
- price(symbol, nowMs) {
54
- const seen = options.sources.map((source) => source.latest(symbol)).filter((quote) => quote !== null);
55
- if (seen.length === 0) {
56
- throw new MarketError("UNKNOWN_SYMBOL", `No venue has a price for ${symbol}.`);
57
- }
58
- const fresh = seen.filter((quote) => !isStale(quote, options.policy, nowMs));
59
- if (fresh.length === 0) {
60
- const youngest = Math.min(...seen.map((quote) => stalenessOf(quote, nowMs)));
61
- throw new MarketError("STALE", `Every price for ${symbol} is stale — the youngest is ${youngest}ms old, and the limit is ` + `${limitFor(options.policy, symbol)}ms. Returning it anyway is how a stale book prices a liquidation.`, { youngestAgeMs: youngest });
62
- }
63
- const spread = disagreementOf(fresh);
64
- if (options.maxDisagreement !== undefined && spread > options.maxDisagreement) {
65
- throw new MarketError("NO_PRICE", `Venues disagree on ${symbol} by ${(spread * 100).toFixed(2)}%, past the ` + `${(options.maxDisagreement * 100).toFixed(2)}% limit. One of them is wrong and this cannot say which.`, { spread });
66
- }
67
- const chosen = [...fresh].sort((a, b) => rank(a.venue) - rank(b.venue) || b.atMs - a.atMs)[0];
68
- return { quote: chosen, ageMs: stalenessOf(chosen, nowMs), considered: fresh, spread };
69
- }
70
- };
71
- }
72
- var lastPrice = (feed, symbol, nowMs) => feed.price(symbol, nowMs).quote.price;
73
- function subscribe(feed, args) {
74
- const last = new Map;
75
- const poll = () => {
76
- const nowMs = args.now();
77
- for (const symbol of args.symbols) {
78
- let signature;
79
- let event;
80
- try {
81
- const result = feed.price(symbol, nowMs);
82
- signature = `price:${result.quote.venue}:${result.quote.price.units}`;
83
- event = { type: "price", symbol, result };
84
- } catch (cause) {
85
- if (!(cause instanceof MarketError))
86
- throw cause;
87
- if (cause.code === "NO_PRICE") {
88
- signature = "disagreement";
89
- event = { type: "disagreement", symbol, spread: cause.detail.spread ?? 0 };
90
- } else {
91
- signature = cause.code;
92
- event = { type: "stale", symbol, youngestAgeMs: cause.detail.youngestAgeMs ?? 0 };
93
- }
94
- }
95
- if (last.get(symbol) === signature)
96
- continue;
97
- last.set(symbol, signature);
98
- args.onEvent(event);
99
- }
100
- };
101
- return { poll, seen: () => last };
102
- }
103
- export {
104
- subscribe,
105
- stalenessOf,
106
- limitFor,
107
- lastPrice,
108
- isStale,
109
- disagreementOf,
110
- createFeed,
111
- MarketError
112
- };