@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.
- package/README.md +60 -1133
- package/dist/notify-templates.js +1 -1
- package/dist/schema-typebox.js +116 -7
- package/dist/schema.d.ts +14 -1
- package/dist/schema.js +116 -7
- package/package.json +2 -58
- package/dist/compliance.d.ts +0 -172
- package/dist/compliance.js +0 -168
- package/dist/finance/chain-addresses.d.ts +0 -130
- package/dist/finance/chain-addresses.js +0 -462
- package/dist/finance/chain-deposits.d.ts +0 -193
- package/dist/finance/chain-deposits.js +0 -600
- package/dist/finance/chain-reconcile.d.ts +0 -112
- package/dist/finance/chain-reconcile.js +0 -76
- package/dist/finance/chain-withdrawals.d.ts +0 -223
- package/dist/finance/chain-withdrawals.js +0 -635
- package/dist/finance/chain.d.ts +0 -116
- package/dist/finance/chain.js +0 -316
- package/dist/finance/commission.d.ts +0 -155
- package/dist/finance/commission.js +0 -423
- package/dist/finance/custody.d.ts +0 -68
- package/dist/finance/custody.js +0 -107
- package/dist/finance/derive.d.ts +0 -115
- package/dist/finance/derive.js +0 -116
- package/dist/finance/ledger.d.ts +0 -227
- package/dist/finance/ledger.js +0 -313
- package/dist/finance/market.d.ts +0 -209
- package/dist/finance/market.js +0 -112
- package/dist/finance/rates.d.ts +0 -178
- package/dist/finance/rates.js +0 -292
- package/dist/finance/transfers.d.ts +0 -153
- package/dist/finance/transfers.js +0 -292
- package/dist/finance/venues.d.ts +0 -190
- package/dist/finance/venues.js +0 -251
package/dist/finance/ledger.js
DELETED
|
@@ -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
|
-
};
|
package/dist/finance/market.d.ts
DELETED
|
@@ -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
|
-
};
|
package/dist/finance/market.js
DELETED
|
@@ -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
|
-
};
|