canli-backtest-mcp 0.3.0 → 0.5.0

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/src/engine.mjs CHANGED
@@ -15,9 +15,65 @@
15
15
  export const ENGINE_LIMITS_TEXT = Object.freeze([
16
16
  "Prices are the caller's: survivorship, splits, dividends and data errors in them pass straight into the result.",
17
17
  "A backtest measures one path of history under these costs; it does not establish that the signal will keep working.",
18
- "Costs are a flat rate per unit traded; market impact, borrow fees for shorts and financing are not charged unless included in cost_bps.",
18
+ "Costs are a flat rate per unit traded; market impact is charged only when capital and dollar volume are given, and borrow fees for shorts and financing are not charged unless included in cost_bps.",
19
19
  ]);
20
20
 
21
+ // With capital and dollar volume, size meets the market. Average daily dollar volume and daily
22
+ // volatility are read from the days before each trade, never the trade day. An order fills only up
23
+ // to max participation of that volume (the rest stays unfilled and the old position stays open), and
24
+ // pays square-root impact, the same law check_feasibility uses: Y x daily volatility x sqrt(participation)
25
+ // (Toth et al. 2011). A name with too little volume history cannot trade at all: nothing is assumed.
26
+ const LIQUIDITY_DAYS = 20, MIN_LIQUIDITY_DAYS = 10;
27
+ export const FILL_RATIO_TRADABLE = 0.95;
28
+
29
+ function liquidityAt(prices, volumes, t) {
30
+ let dollars = 0, days = 0;
31
+ const rets = [];
32
+ for (let i = Math.max(1, t - LIQUIDITY_DAYS); i < t; i += 1) {
33
+ if (finite(volumes[i]) && volumes[i] > 0) { dollars += volumes[i]; days += 1; }
34
+ const a = prices[i - 1], b = prices[i];
35
+ if (finite(a) && finite(b) && a > 0) rets.push(b / a - 1);
36
+ }
37
+ if (days < MIN_LIQUIDITY_DAYS || rets.length < MIN_LIQUIDITY_DAYS) return null;
38
+ const mean = rets.reduce((x, y) => x + y, 0) / rets.length;
39
+ return { adv: dollars / days, vol: Math.sqrt(rets.reduce((x, y) => x + (y - mean) ** 2, 0) / (rets.length - 1)) };
40
+ }
41
+
42
+ // Whole stretches of market history a strategy can be held against. Dates are peak to trough or the
43
+ // episode's usual bounds; the first eight are the ones stress_worlds drops into its worlds.
44
+ export const CRISES = Object.freeze({
45
+ dotcom_bust: { from: "2000-03-24", to: "2002-10-09", what: "the 2000-02 dot-com bust" },
46
+ bank_crisis: { from: "2008-09-02", to: "2009-03-09", what: "the 2008-09 bank crisis" },
47
+ debt_ceiling_2011: { from: "2011-04-29", to: "2011-10-03", what: "the 2011 US downgrade and euro debt scare" },
48
+ taper_tantrum: { from: "2013-05-22", to: "2013-09-05", what: "the 2013 taper tantrum in bonds" },
49
+ oil_collapse: { from: "2014-06-20", to: "2016-02-11", what: "the 2014-16 oil collapse" },
50
+ dollar_surge: { from: "2014-07-01", to: "2015-03-13", what: "the 2014-15 surge in the dollar" },
51
+ q4_2018: { from: "2018-09-20", to: "2018-12-24", what: "the late 2018 sell-off" },
52
+ pandemic_crash: { from: "2020-02-19", to: "2020-03-23", what: "the February-March 2020 crash" },
53
+ inflation_shock: { from: "2022-01-03", to: "2022-06-16", what: "the 2022 inflation and rate shock" },
54
+ compute_bust: { from: "2022-01-03", to: "2022-10-14", what: "the 2022 fall in chip and compute stocks" },
55
+ regional_bank_run: { from: "2023-03-01", to: "2023-03-24", what: "the March 2023 regional bank run" },
56
+ });
57
+ export const CRISIS_DRAWDOWN_LIMIT = 0.5;
58
+
59
+ // The strategy's own returns inside every crisis its dates fully cover. A crisis the run does not
60
+ // fully cover is listed as untested: not having been there is not the same as having survived it.
61
+ export function crisisReplay({ dates, returns, limit = CRISIS_DRAWDOWN_LIMIT }) {
62
+ if (!(limit > 0 && limit <= 1)) throw new RangeError("the crisis drawdown limit must be above 0 and at most 1");
63
+ const tested = [], untested = [];
64
+ for (const [id, c] of Object.entries(CRISES)) {
65
+ if (!(dates[0] <= c.from && dates.at(-1) >= c.to)) { untested.push({ id, what: c.what, from: c.from, to: c.to }); continue; }
66
+ let wealth = 1, peak = 1, drawdown = 0, days = 0, worst = 0;
67
+ for (let i = 0; i < dates.length; i += 1) {
68
+ if (dates[i] < c.from || dates[i] > c.to) continue;
69
+ wealth *= 1 + returns[i]; peak = Math.max(peak, wealth); drawdown = Math.max(drawdown, 1 - wealth / peak); worst = Math.min(worst, returns[i]); days += 1;
70
+ }
71
+ tested.push({ id, what: c.what, from: c.from, to: c.to, days, return: wealth - 1, drawdown, worst_day: worst, ruptured: drawdown > limit });
72
+ }
73
+ const verdict = !tested.length ? "untested" : tested.some((c) => c.ruptured) ? "ruptured" : "survived_tested";
74
+ return { verdict, drawdown_limit: limit, tested, untested };
75
+ }
76
+
21
77
  const REBALANCE = Object.freeze({ weekly: "week", monthly: "month", quarterly: "quarter" });
22
78
  const periodKey = (date, every) => {
23
79
  const [y, m] = date.split("-").map(Number);
@@ -70,12 +126,20 @@ function lastRowOnOrBefore(signalDates, bound) {
70
126
  return found;
71
127
  }
72
128
 
73
- export function runBacktest({ dates, tickers, prices, signalDates, signals, rebalance = "monthly", lagDays = 1, side = "long_short", quantile = 0.2, costBps = 10, minNames = 5, periodsPerYear = 252 }) {
129
+ export function runBacktest({ dates, tickers, prices, signalDates, signals, rebalance = "monthly", lagDays = 1, side = "long_short", quantile = 0.2, costBps = 10, minNames = 5, periodsPerYear = 252, capital = null, dollarVolumes = null, maxParticipation = 0.1, impactCoefficient = 1 }) {
74
130
  checkInputs({ dates, tickers, prices, signalDates, signals });
75
131
  if (!Number.isInteger(lagDays) || lagDays < 1) throw new RangeError("lag_days must be a whole number of at least 1: a signal cannot trade on the day it becomes known");
76
132
  if (!(quantile > 0 && quantile <= 0.5)) throw new RangeError("quantile must be in (0, 0.5]");
77
133
  if (!["long_short", "long_only"].includes(side)) throw new RangeError("side must be long_short or long_only");
78
134
  if (!(costBps >= 0)) throw new RangeError("cost_bps cannot be negative");
135
+ const sized = capital !== null;
136
+ if (sized) {
137
+ if (!(capital > 0)) throw new RangeError("capital must be above zero");
138
+ if (!dollarVolumes) throw new RangeError("capital needs dollar volume: without it nothing says how much the market can take");
139
+ if (!(maxParticipation > 0 && maxParticipation <= 1)) throw new RangeError("max participation must be above 0 and at most 1");
140
+ if (!(impactCoefficient >= 0)) throw new RangeError("the impact coefficient cannot be negative");
141
+ }
142
+ const liq = { impact: 0, orders: 0, clipped: 0, blocked: 0, wanted: 0, filled: 0, maxFilled: 0, worst: null };
79
143
 
80
144
  const n = dates.length;
81
145
  const rebal = new Set(rebalanceIndices(dates, rebalance, lagDays));
@@ -116,9 +180,26 @@ export function runBacktest({ dates, tickers, prices, signalDates, signals, reba
116
180
  const long = ranked.slice(0, k), short = side === "long_short" ? ranked.slice(-k) : [];
117
181
  for (const tk of long) target.set(tk, nav / long.length);
118
182
  for (const tk of short) target.set(tk, (target.get(tk) ?? 0) - nav / short.length);
119
- let traded = 0;
120
- for (const tk of new Set([...pos.keys(), ...target.keys()])) traded += Math.abs((target.get(tk) ?? 0) - (pos.get(tk) ?? 0));
121
- const cost = traded * costBps / 1e4;
183
+ let traded = 0, impact = 0;
184
+ for (const tk of new Set([...pos.keys(), ...target.keys()])) {
185
+ const held = pos.get(tk) ?? 0, want = (target.get(tk) ?? 0) - held;
186
+ if (!sized) { traded += Math.abs(want); continue; }
187
+ if (want === 0) continue;
188
+ const l = liquidityAt(prices[tk], dollarVolumes[tk] ?? [], t);
189
+ const wanted = Math.abs(want);
190
+ liq.orders += 1; liq.wanted += wanted;
191
+ if (!l) { liq.blocked += 1; target.set(tk, held); continue; }
192
+ const room = maxParticipation * l.adv / capital;
193
+ const wantedShare = wanted * capital / l.adv;
194
+ if (!liq.worst || wantedShare > liq.worst.wanted_participation) liq.worst = { ticker: tk, date: dates[t], wanted_participation: wantedShare, average_daily_dollars: l.adv };
195
+ const fill = Math.min(wanted, room);
196
+ if (fill < wanted) { liq.clipped += 1; target.set(tk, held + Math.sign(want) * fill); }
197
+ const share = fill * capital / l.adv;
198
+ impact += fill * impactCoefficient * l.vol * Math.sqrt(share);
199
+ traded += fill; liq.filled += fill; liq.maxFilled = Math.max(liq.maxFilled, share);
200
+ }
201
+ liq.impact += impact;
202
+ const cost = traded * costBps / 1e4 + impact;
122
203
  nav -= cost;
123
204
  const factor = 1 - cost / (nav + cost);
124
205
  if (started && out.returns.length) {
@@ -133,7 +214,14 @@ export function runBacktest({ dates, tickers, prices, signalDates, signals, reba
133
214
  started = true;
134
215
  }
135
216
  if (!rebalances) throw new RangeError("no rebalance had enough names with both a known signal and a price; check the dates overlap and min_names");
136
- return { ...out, used, nav, rebalances, turnoverSum, costSum, missingDays, longNames, shortNames, periodsPerYear };
217
+ const fillRatio = liq.wanted > 0 ? liq.filled / liq.wanted : 1;
218
+ const liquidity = sized ? {
219
+ capital_usd: capital, max_participation: maxParticipation, impact_coefficient: impactCoefficient,
220
+ impact_paid: liq.impact, orders: liq.orders, clipped_orders: liq.clipped, blocked_orders: liq.blocked,
221
+ fill_ratio: fillRatio, filled_participation_max: liq.maxFilled, worst: liq.worst,
222
+ verdict: fillRatio >= FILL_RATIO_TRADABLE ? "tradable" : "too_big",
223
+ } : undefined;
224
+ return { ...out, used, nav, rebalances, turnoverSum, costSum, missingDays, longNames, shortNames, periodsPerYear, ...(sized ? { liquidity } : {}) };
137
225
  }
138
226
 
139
227
  // Plain statistics of a return series, in the same Sharpe convention as the validators (sample sd).
@@ -0,0 +1,439 @@
1
+ // The agent exchange: a closed paper market where different AI agents, and scripted personas beside
2
+ // them, trade against each other in limit order books. It is for seeing what agents do together: who
3
+ // supplies liquidity, who runs when it goes, and how forced selling feeds on itself. Made-up assets and
4
+ // made-up money, and nothing can leave the file.
5
+ //
6
+ // The rules are the protocol (docs/AGENT_EXCHANGE.md). Time moves in steps, not milliseconds, so no
7
+ // agent wins by being on a faster machine: everyone acts on the same view, and the order they act in
8
+ // within a step comes from the seed. The tape of every trade is hash-chained, so the same seed and the
9
+ // same orders give the same hash on any machine, and anyone can check a claimed run by replaying it.
10
+ //
11
+ // Running a real market like this needs registration as an exchange or a broker-dealer. This is not one.
12
+ import { createHash, randomBytes } from "node:crypto";
13
+ import { closeSync, existsSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
14
+ import { z } from "zod";
15
+
16
+ export const EXCHANGE_LIMITS_TEXT = Object.freeze([
17
+ "Paper only: made-up assets and made-up money. Nothing here represents or can reach a real security, token or currency.",
18
+ "A simulated market leaves out most of what moves real ones: no real news, no real funding, no real people. It shows how a set of rules can behave together, and it does not predict any real market.",
19
+ "Personas are simple scripts with parameters someone chose. Different parameters give different cascades, so a result here is a question to ask of a real system, not an answer about it.",
20
+ ]);
21
+
22
+ const file = z.string().min(1).max(1000);
23
+ const agentId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{1,39}$/).refine((n) => !(n in {}), "that name is reserved");
24
+ const assetId = z.string().regex(/^ART[0-9]{1,2}$/, "an exchange asset, such as ART1");
25
+ const PERSONAS = ["market_maker", "value", "momentum", "noise", "deleverager", "stop_loss", "levered_long"];
26
+
27
+ export const exchangeNewInput = z.object({
28
+ exchange_file: file.describe("The exchange's state file; must not exist yet."),
29
+ seed: z.number().int().min(0).max(2 ** 31 - 2).optional().describe("The same seed and the same orders give the same market. Default 1."),
30
+ assets: z.number().int().min(1).max(10).optional().describe("Made-up assets. Default 1."),
31
+ personas: z.object(Object.fromEntries(PERSONAS.map((p) => [p, z.number().int().min(0).max(200).optional()]))).strict().optional().describe("How many scripted traders of each kind. Default 8 market makers, 8 value, 6 momentum, 8 noise, 4 deleveragers, 4 stop-loss, 4 levered longs."),
32
+ starting_cash: z.number().positive().max(1e9).optional().describe("Made-up money each agent starts with. Default 100000."),
33
+ maintenance_margin: z.number().min(0.05).max(0.9).optional().describe("An agent whose equity falls below this share of its gross positions is sold out by force. Default 0.25."),
34
+ leverage: z.number().min(1).max(10).optional().describe("How many times its equity an agent may hold in positions when it opens them. Default 2."),
35
+ shocks: z.array(z.object({
36
+ step: z.number().int().min(1).max(100000),
37
+ kind: z.enum(["value", "liquidity", "block_sale"]).describe("value: the asset's true worth jumps. liquidity: market makers pull their quotes for some steps. block_sale: one large holder sells at market."),
38
+ asset: assetId.optional(),
39
+ percent: z.number().min(-90).max(500).optional().describe("For value: how far it jumps."),
40
+ steps: z.number().int().min(1).max(1000).optional().describe("For liquidity: how long; default 10."),
41
+ quantity: z.number().int().positive().max(1e7).optional().describe("For block_sale."),
42
+ }).strict()).max(200).optional().describe("Things that happen at set steps. Agents see them only through prices."),
43
+ }).strict();
44
+
45
+ const order = z.discriminatedUnion("type", [
46
+ z.object({ type: z.literal("limit"), asset: assetId, side: z.enum(["buy", "sell"]), quantity: z.number().int().positive().max(1e7), price: z.number().positive().max(1e7) }).strict(),
47
+ z.object({ type: z.literal("market"), asset: assetId, side: z.enum(["buy", "sell"]), quantity: z.number().int().positive().max(1e7) }).strict(),
48
+ z.object({ type: z.literal("cancel"), order_id: z.number().int().positive().optional().describe("Without it, all your resting orders go.") }).strict(),
49
+ ]);
50
+ export const exchangeJoinInput = z.object({ exchange_file: file, agent: agentId, about: z.string().max(200).optional().describe("What this agent is, such as its model and its brief. Shown in the report.") }).strict();
51
+ const access = { exchange_file: file, agent: agentId, token: z.string().min(16).max(80).describe("The token exchange_join gave this agent, so no agent can act as another.") };
52
+ export const exchangeViewInput = z.object({ ...access, depth: z.number().int().min(1).max(20).optional().describe("Price levels to show on each side; default 5.") }).strict();
53
+ export const exchangeOrdersInput = z.object({ ...access, orders: z.array(order).max(50).describe("What to do in the coming step. An empty list says you are passing, so the step can close.") }).strict();
54
+ export const exchangeStepInput = z.object({ exchange_file: file, steps: z.number().int().min(1).max(5000).optional().describe("Default 1."), force: z.boolean().optional().describe("Close the step even though a joined agent has not acted.") }).strict();
55
+ export const exchangeReportInput = z.object({ exchange_file: file }).strict();
56
+
57
+ // One writer at a time, whichever process or host it is: each joined agent may run its own server.
58
+ function locked(path, work) {
59
+ const lock = `${path}.lock`;
60
+ for (let tries = 0; ; tries += 1) {
61
+ try { closeSync(openSync(lock, "wx")); break; } catch (err) {
62
+ if (err.code !== "EEXIST") throw err;
63
+ try { if (Date.now() - statSync(lock).mtimeMs > 15000) { rmSync(lock, { force: true }); continue; } } catch { continue; }
64
+ if (tries > 400) throw new Error(`${path} is held by another writer.`);
65
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
66
+ }
67
+ }
68
+ try { return work(); } finally { rmSync(lock, { force: true }); }
69
+ }
70
+ const load = (f) => { if (!existsSync(f)) throw new RangeError(`${f} is not an exchange yet: call exchange_new`); let s = null; try { s = JSON.parse(readFileSync(f, "utf8")); } catch { /* a parse error quotes the file, so it stays here */ } if (s?.schema !== "canli.exchange.v1") throw new RangeError(`${f} is not an agent exchange file`); return s; };
71
+ const save = (f, s) => { const tmp = `${f}.${process.pid}.tmp`; writeFileSync(tmp, JSON.stringify(s)); renameSync(tmp, f); };
72
+
73
+ const cents = (x) => Math.round(x * 100) / 100;
74
+ function uniform(s) { s.rng = (s.rng * 16807) % 2147483647; return s.rng / 2147483647; }
75
+ const gauss = (s) => Math.sqrt(-2 * Math.log(uniform(s))) * Math.cos(2 * Math.PI * uniform(s));
76
+ const sha = (x) => createHash("sha256").update(x).digest("hex");
77
+
78
+ const worth = (s, me) => me.cash + s.assets.reduce((n, a) => n + (me.holdings[a.id] ?? 0) * a.last, 0);
79
+ const gross = (s, me) => s.assets.reduce((n, a) => n + Math.abs(me.holdings[a.id] ?? 0) * a.last, 0);
80
+
81
+ // Everyone starts with a stake worth half its cash in each asset between them, so positions and money
82
+ // are of the same size and no one is a giant by accident.
83
+ function addAgent(s, name, persona, about, holdings = null) {
84
+ const each = s.config.starting_cash * 0.5 / s.assets.length;
85
+ s.agents[name] = { cash: s.config.starting_cash, holdings: holdings ?? Object.fromEntries(s.assets.map((a) => [a.id, Math.round(each / a.last)])), persona, ...(about ? { about } : {}), forced: 0, trades: 0 };
86
+ s.agents[name].start = worth(s, s.agents[name]);
87
+ }
88
+
89
+ export function exchangeNew(args) {
90
+ const a = exchangeNewInput.parse(args);
91
+ if (existsSync(a.exchange_file)) throw new RangeError(`${a.exchange_file} already exists`);
92
+ const s = createExchange(a);
93
+ save(a.exchange_file, s);
94
+ return { exchange_file: a.exchange_file, assets: s.assets.map((x) => ({ id: x.id, last: x.last })), personas: Object.keys(s.agents).length, shocks: s.shocks.length, plain_reading: "A new paper exchange: made-up assets and made-up money. Each agent calls exchange_join, then exchange_view and exchange_orders; exchange_step moves time and exchange_report sums it up.", limits: EXCHANGE_LIMITS_TEXT };
95
+ }
96
+
97
+ export function createExchange(a) {
98
+ const s = {
99
+ schema: "canli.exchange.v1", paper_only: true, seed: a.seed ?? 1, rng: (a.seed ?? 1) + 1, step: 0, next_order: 1,
100
+ config: { starting_cash: a.starting_cash ?? 100000, maintenance_margin: a.maintenance_margin ?? 0.25, leverage: a.leverage ?? 2 },
101
+ assets: [], books: {}, agents: {}, tokens: {}, joined: [], pending: {}, tape: [], tape_count: 0, tape_hash: sha(`canli.exchange.v1|${a.seed ?? 1}`),
102
+ shocks: a.shocks ?? [], withdrawn_until: 0, history: [], rejected: 0,
103
+ };
104
+ for (let i = 0; i < (a.assets ?? 1); i += 1) {
105
+ const value = cents(50 + 100 * uniform(s)), id = `ART${i + 1}`;
106
+ s.assets.push({ id, value, last: value, peak: value });
107
+ s.books[id] = { bids: [], asks: [] };
108
+ }
109
+ const counts = { market_maker: 8, value: 8, momentum: 6, noise: 8, deleverager: 4, stop_loss: 4, levered_long: 4, ...(a.personas ?? {}) };
110
+ for (const kind of PERSONAS) for (let i = 0; i < (counts[kind] ?? 0); i += 1) addAgent(s, `${kind}-${i}`, kind);
111
+ // Whoever makes a block sale holds the block from the start, so nothing appears from nowhere.
112
+ const blocks = s.shocks.filter((x) => x.kind === "block_sale");
113
+ if (blocks.length) {
114
+ const held = Object.fromEntries(s.assets.map((x) => [x.id, 0]));
115
+ for (const b of blocks) { const id = b.asset ?? s.assets[0].id; held[id] += b.quantity ?? 1000; }
116
+ addAgent(s, "block-seller", "block_seller", null, held);
117
+ }
118
+ return s;
119
+ }
120
+
121
+ const tokenHash = (t) => sha(`exchange-token|${t}`);
122
+ export function exchangeJoin(args) {
123
+ const a = exchangeJoinInput.parse(args);
124
+ return locked(a.exchange_file, () => {
125
+ const s = load(a.exchange_file);
126
+ if (Object.hasOwn(s.agents, a.agent)) throw new RangeError(`${a.agent} is already in this exchange.`);
127
+ if (s.step > 0 && s.joined.length === 0 && Object.keys(s.tokens).length === 0) { /* late joiners are allowed; they start with the same stake */ }
128
+ addAgent(s, a.agent, "agent", a.about);
129
+ const token = randomBytes(24).toString("hex");
130
+ s.tokens[a.agent] = tokenHash(token);
131
+ s.joined.push(a.agent);
132
+ save(a.exchange_file, s);
133
+ return { agent: a.agent, token, step: s.step, plain_reading: "Keep the token: every call you make needs it. Call exchange_view to see the market, then exchange_orders each step, an empty list to pass.", limits: EXCHANGE_LIMITS_TEXT };
134
+ });
135
+ }
136
+ const check = (s, agent, token) => { if (s.tokens[agent] !== tokenHash(token)) throw new RangeError(`That token is not ${agent}'s.`); };
137
+
138
+ const levels = (side, depth) => {
139
+ const out = [];
140
+ for (const o of side) { if (out.length && out.at(-1)[0] === o.price) out.at(-1)[1] += o.quantity; else if (out.length < depth) out.push([o.price, o.quantity]); else break; }
141
+ return out;
142
+ };
143
+
144
+ // An agent sees the public market and its own account: no other agent's positions, and no true value.
145
+ export function exchangeView(args) {
146
+ const a = exchangeViewInput.parse(args), s = load(a.exchange_file);
147
+ check(s, a.agent, a.token);
148
+ const me = s.agents[a.agent], depth = a.depth ?? 5;
149
+ const open = [];
150
+ for (const asset of s.assets) for (const side of ["bids", "asks"]) for (const o of s.books[asset.id][side]) if (o.agent === a.agent) open.push({ order_id: o.id, asset: asset.id, side: side === "bids" ? "buy" : "sell", quantity: o.quantity, price: o.price });
151
+ const equity = worth(s, me), pos = gross(s, me);
152
+ return {
153
+ step: s.step,
154
+ market: s.assets.map((x) => ({ asset: x.id, last: x.last, bids: levels(s.books[x.id].bids, depth), asks: levels(s.books[x.id].asks, depth) })),
155
+ recent_trades: s.tape.slice(-20).map((t) => ({ step: t.step, asset: t.asset, price: t.price, quantity: t.quantity })),
156
+ you: { cash: cents(me.cash), holdings: me.holdings, open_orders: open, equity: cents(equity), margin: pos ? Number((equity / pos).toFixed(3)) : null, forced_sales: me.forced },
157
+ rules: `Positions may reach ${s.config.leverage} times equity when opened. Below ${s.config.maintenance_margin} equity to gross positions, an account is sold out at market. Orders go in for the coming step.`,
158
+ waiting_for: s.joined.filter((n) => !Object.hasOwn(s.pending, n)),
159
+ };
160
+ }
161
+
162
+ export function exchangeOrders(args) {
163
+ const a = exchangeOrdersInput.parse(args);
164
+ return locked(a.exchange_file, () => {
165
+ const s = load(a.exchange_file);
166
+ check(s, a.agent, a.token);
167
+ const known = new Set(s.assets.map((x) => x.id));
168
+ for (const o of a.orders) if (o.asset && !known.has(o.asset)) throw new RangeError(`No asset ${o.asset} in this exchange.`);
169
+ s.pending[a.agent] = a.orders;
170
+ save(a.exchange_file, s);
171
+ return { agent: a.agent, orders_waiting: a.orders.length, step: s.step, waiting_for: s.joined.filter((n) => !Object.hasOwn(s.pending, n)), plain_reading: `Your ${a.orders.length} orders go in at step ${s.step + 1}.` };
172
+ });
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------------------------
176
+ // Matching
177
+ // ---------------------------------------------------------------------------------------------
178
+ function trade(s, asset, buyer, seller, price, quantity, forced) {
179
+ const b = s.agents[buyer], c = s.agents[seller], cost = cents(price * quantity);
180
+ b.cash = cents(b.cash - cost); c.cash = cents(c.cash + cost);
181
+ b.holdings[asset.id] = (b.holdings[asset.id] ?? 0) + quantity;
182
+ c.holdings[asset.id] = (c.holdings[asset.id] ?? 0) - quantity;
183
+ b.trades += 1; c.trades += 1;
184
+ asset.last = price;
185
+ asset.peak = Math.max(asset.peak, price);
186
+ const line = { step: s.step, asset: asset.id, price, quantity, buyer, seller, ...(forced ? { forced: true } : {}) };
187
+ s.tape_count += 1;
188
+ s.tape_hash = sha(`${s.tape_hash}|${s.step}|${asset.id}|${price}|${quantity}|${buyer}|${seller}|${forced ? 1 : 0}`);
189
+ s.tape.push(line);
190
+ if (s.tape.length > 2000) s.tape.shift();
191
+ s.now.volume[asset.id] = (s.now.volume[asset.id] ?? 0) + quantity;
192
+ if (forced) s.now.forced_volume += quantity;
193
+ }
194
+
195
+ // Price, then time. A trade happens at the resting order's price. An order never trades with its own
196
+ // agent: the resting one goes.
197
+ function match(s, asset, side, agent, quantity, limit, forced) {
198
+ const book = s.books[asset.id], against = side === "buy" ? book.asks : book.bids;
199
+ let left = quantity;
200
+ while (left > 0 && against.length) {
201
+ const top = against[0];
202
+ if (limit != null && (side === "buy" ? top.price > limit : top.price < limit)) break;
203
+ if (top.agent === agent) { against.shift(); continue; }
204
+ const q = Math.min(left, top.quantity);
205
+ if (side === "buy") trade(s, asset, agent, top.agent, top.price, q, forced); else trade(s, asset, top.agent, agent, top.price, q, forced);
206
+ left -= q; top.quantity -= q;
207
+ if (top.quantity === 0) against.shift();
208
+ }
209
+ return left;
210
+ }
211
+ const MOST_RESTING = 2000;
212
+ function rest(s, asset, side, agent, quantity, price) {
213
+ const list = side === "buy" ? s.books[asset.id].bids : s.books[asset.id].asks;
214
+ // A book this deep is refused more orders, so no one can make matching slow for everyone.
215
+ if (list.length >= MOST_RESTING) { s.rejected += 1; return null; }
216
+ const o = { id: s.next_order, agent, quantity, price };
217
+ s.next_order += 1;
218
+ let i = list.length;
219
+ while (i > 0 && (side === "buy" ? list[i - 1].price < price : list[i - 1].price > price)) i -= 1;
220
+ list.splice(i, 0, o);
221
+ return o;
222
+ }
223
+ function cancelAll(s, agent, assetId = null) {
224
+ for (const a of s.assets) {
225
+ if (assetId && a.id !== assetId) continue;
226
+ for (const side of ["bids", "asks"]) s.books[a.id][side] = s.books[a.id][side].filter((o) => o.agent !== agent);
227
+ }
228
+ }
229
+
230
+ // Would this order, if all of it filled, keep positions within the leverage limit? A trade that only
231
+ // shrinks a position is always allowed.
232
+ function allowed(s, agent, assetObj, side, quantity, price) {
233
+ const me = s.agents[agent], have = me.holdings[assetObj.id] ?? 0, after = have + (side === "buy" ? quantity : -quantity);
234
+ if (Math.abs(after) <= Math.abs(have)) return true;
235
+ const equity = worth(s, me), others = gross(s, me) - Math.abs(have) * assetObj.last;
236
+ return equity > 0 && (others + Math.abs(after) * (price ?? assetObj.last)) <= equity * s.config.leverage;
237
+ }
238
+
239
+ function apply(s, agent, o) {
240
+ if (o.type === "cancel") {
241
+ if (o.order_id) for (const a of s.assets) for (const side of ["bids", "asks"]) s.books[a.id][side] = s.books[a.id][side].filter((x) => !(x.id === o.order_id && x.agent === agent));
242
+ else cancelAll(s, agent);
243
+ return;
244
+ }
245
+ const asset = s.assets.find((x) => x.id === o.asset);
246
+ if (!allowed(s, agent, asset, o.side, o.quantity, o.price)) { s.rejected += 1; return; }
247
+ if (o.type === "market") { match(s, asset, o.side, agent, o.quantity, null, false); return; }
248
+ const price = Math.max(0.01, cents(o.price)), left = match(s, asset, o.side, agent, o.quantity, price, false);
249
+ if (left > 0) rest(s, asset, o.side, agent, left, price);
250
+ }
251
+
252
+ // Accounts below the maintenance margin are sold down until they are back above it, at market, into
253
+ // whatever is left in the book. Each sale moves the price, which can put the next account under: a
254
+ // cascade, counted in rounds.
255
+ function enforceMargin(s) {
256
+ for (let round = 1; round <= 25; round += 1) {
257
+ let any = false;
258
+ for (const name of Object.keys(s.agents)) {
259
+ const me = s.agents[name], pos = gross(s, me);
260
+ if (!pos || worth(s, me) / pos >= s.config.maintenance_margin) continue;
261
+ const before = pos;
262
+ cancelAll(s, name);
263
+ for (const asset of s.assets) {
264
+ const have = me.holdings[asset.id] ?? 0;
265
+ if (!have) continue;
266
+ const q = Math.max(1, Math.ceil(Math.abs(have) / 2));
267
+ const left = match(s, asset, have > 0 ? "sell" : "buy", name, q, null, true);
268
+ if (left < q) { any = true; me.forced += 1; s.now.forced_accounts.add(name); }
269
+ }
270
+ if (gross(s, me) === before) continue;
271
+ }
272
+ if (!any) break;
273
+ s.now.cascade_rounds = round;
274
+ }
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------------------------
278
+ // Personas: simple scripts, each seeing what any agent sees (the persona value also sees a noisy
279
+ // version of true worth). Their numbers are stated here because they are the assumptions.
280
+ // ---------------------------------------------------------------------------------------------
281
+ const recentReturn = (s, id, k) => { const h = s.history.slice(-k), first = h[0]?.prices?.[id]; const last = s.assets.find((a) => a.id === id).last; return first ? last / first - 1 : 0; };
282
+ function personaOrders(s, name, me) {
283
+ const out = [];
284
+ if (me.bust) return out;
285
+ const withdrawn = s.step < s.withdrawn_until;
286
+ const equity = worth(s, me);
287
+ if (equity <= 0) { me.bust = true; return [{ type: "cancel" }]; }
288
+ // Makers lift all their quotes once and post afresh, so quoting one asset does not cancel another's.
289
+ if (me.persona === "market_maker") out.push({ type: "cancel" });
290
+ for (const asset of s.assets) {
291
+ const last = asset.last, have = me.holdings[asset.id] ?? 0, id = asset.id;
292
+ // A size as a share of this agent's own equity, in shares, at least one.
293
+ const shares = (part) => Math.max(1, Math.floor(equity * part / s.assets.length / last));
294
+ if (me.persona === "market_maker") {
295
+ // Makers step back on their own when prices are moving fast, as well as when a shock tells them to.
296
+ const moving = Math.abs(recentReturn(s, id, 3)) > 0.03;
297
+ if (withdrawn || moving) continue;
298
+ const home = Math.round(equity * 0.33 / s.assets.length / last), lean = Math.max(-1, Math.min(1, (have - home) / Math.max(1, home)));
299
+ const half = last * 0.001 * (1 + uniform(s)), mid = last * (1 - lean * 0.002), size = shares(0.01 + uniform(s) * 0.01);
300
+ for (let level = 0; level < 3; level += 1) {
301
+ out.push({ type: "limit", asset: id, side: "buy", quantity: size, price: Math.max(0.01, cents(mid - half * (level + 1))) });
302
+ out.push({ type: "limit", asset: id, side: "sell", quantity: size, price: cents(mid + half * (level + 1)) });
303
+ }
304
+ } else if (me.persona === "value") {
305
+ const seen = asset.value * (1 + 0.02 * gauss(s)), gap = seen / last - 1;
306
+ if (Math.abs(gap) > 0.01) out.push({ type: "limit", asset: id, side: gap > 0 ? "buy" : "sell", quantity: shares(Math.min(0.03, Math.abs(gap) * 0.2)), price: cents(last * (1 + gap / 4)) });
307
+ } else if (me.persona === "momentum") {
308
+ const signal = recentReturn(s, id, 5);
309
+ if (Math.abs(signal) > 0.005) out.push({ type: "market", asset: id, side: signal > 0 ? "buy" : "sell", quantity: shares(Math.min(0.02, Math.abs(signal) * 0.5)) });
310
+ } else if (me.persona === "noise") {
311
+ if (uniform(s) < 0.5) out.push({ type: uniform(s) < 0.3 ? "market" : "limit", asset: id, side: uniform(s) < 0.5 ? "buy" : "sell", quantity: shares(0.002 + uniform(s) * 0.003), price: cents(last * (1 + 0.003 * gauss(s))) });
312
+ if (uniform(s) < 0.1) out.push({ type: "cancel" });
313
+ } else if (me.persona === "deleverager") {
314
+ // Keeps its risk level: sells as volatility rises, buys back when it falls.
315
+ const h = s.history.slice(-10).map((x) => x.prices[id]).filter(Boolean);
316
+ let vol = 0.003;
317
+ if (h.length > 2) { const r = h.slice(1).map((p, i) => p / h[i] - 1), m = r.reduce((x, y) => x + y, 0) / r.length; vol = Math.sqrt(r.reduce((x, y) => x + (y - m) ** 2, 0) / r.length); }
318
+ const target = Math.floor(Math.min(s.config.leverage * 0.8, 0.004 / Math.max(vol, 0.0005)) * equity / s.assets.length / last);
319
+ const trim = target - have, most = shares(0.03);
320
+ if (Math.abs(trim) >= most / 4) out.push({ type: "market", asset: id, side: trim > 0 ? "buy" : "sell", quantity: Math.min(most, Math.abs(trim)) });
321
+ } else if (me.persona === "stop_loss") {
322
+ const high = Math.max(last, ...s.history.slice(-100).map((x) => x.prices[id]).filter(Boolean));
323
+ if (have > 0 && last < high * 0.93) out.push({ type: "market", asset: id, side: "sell", quantity: have });
324
+ else if (have === 0 && last > high * 0.99) out.push({ type: "market", asset: id, side: "buy", quantity: shares(0.33) });
325
+ } else if (me.persona === "levered_long") {
326
+ // Borrows up to the limit and holds, whatever happens, until the margin rule sells it out.
327
+ const target = Math.floor(s.config.leverage * equity * 0.97 / s.assets.length / last);
328
+ if (target > have) out.push({ type: "market", asset: id, side: "buy", quantity: Math.min(target - have, shares(0.02)) });
329
+ }
330
+ }
331
+ return out;
332
+ }
333
+
334
+ function applyShocks(s) {
335
+ for (const x of s.shocks) {
336
+ if (x.step !== s.step) continue;
337
+ const asset = s.assets.find((a) => a.id === (x.asset ?? s.assets[0].id));
338
+ if (!asset) continue;
339
+ if (x.kind === "value") asset.value = Math.max(1, cents(asset.value * (1 + (x.percent ?? -10) / 100)));
340
+ if (x.kind === "liquidity") { s.withdrawn_until = s.step + (x.steps ?? 10); for (const n of Object.keys(s.agents)) if (s.agents[n].persona === "market_maker") cancelAll(s, n); }
341
+ if (x.kind === "block_sale") { const seller = s.agents["block-seller"]; const q = Math.min(x.quantity ?? 1000, seller.holdings[asset.id] ?? 0); if (q > 0) match(s, asset, "sell", "block-seller", q, null, false); }
342
+ }
343
+ }
344
+
345
+ export function stepExchange(s) {
346
+ s.step += 1;
347
+ s.now = { volume: {}, forced_volume: 0, forced_accounts: new Set(), cascade_rounds: 0 };
348
+ applyShocks(s);
349
+ // Everyone acts on the market as it stood at the start of the step, in an order drawn from the seed.
350
+ const plans = Object.entries(s.agents).filter(([, me]) => !["agent", "block_seller"].includes(me.persona)).map(([name, me]) => [name, personaOrders(s, name, me)]);
351
+ for (const name of s.joined) plans.push([name, s.pending[name] ?? []]);
352
+ for (let i = plans.length - 1; i > 0; i -= 1) { const j = Math.floor(uniform(s) * (i + 1)); [plans[i], plans[j]] = [plans[j], plans[i]]; }
353
+ for (const [name, orders] of plans) for (const o of orders) apply(s, name, o);
354
+ s.pending = {};
355
+ enforceMargin(s);
356
+ for (const a of s.assets) a.value = Math.max(1, cents(a.value * (1 + 0.002 * gauss(s))));
357
+ s.history.push({
358
+ step: s.step,
359
+ prices: Object.fromEntries(s.assets.map((a) => [a.id, a.last])),
360
+ spread: Object.fromEntries(s.assets.map((a) => { const b = s.books[a.id].bids[0], c = s.books[a.id].asks[0]; return [a.id, b && c ? cents(c.price - b.price) : null]; })),
361
+ depth: Object.fromEntries(s.assets.map((a) => [a.id, s.books[a.id].bids.reduce((n, o) => n + o.quantity, 0) + s.books[a.id].asks.reduce((n, o) => n + o.quantity, 0)])),
362
+ volume: s.now.volume, forced_volume: s.now.forced_volume, forced_accounts: s.now.forced_accounts.size, cascade_rounds: s.now.cascade_rounds,
363
+ });
364
+ if (s.history.length > 5000) s.history.shift();
365
+ delete s.now;
366
+ }
367
+
368
+ export function exchangeStep(args) {
369
+ const a = exchangeStepInput.parse(args);
370
+ return locked(a.exchange_file, () => {
371
+ const s = load(a.exchange_file);
372
+ const missing = s.joined.filter((n) => !Object.hasOwn(s.pending, n));
373
+ if (missing.length && !a.force) throw new RangeError(`Still waiting for ${missing.join(", ")}. Each must call exchange_orders, an empty list to pass; or pass force to close the step without them.`);
374
+ for (let i = 0; i < (a.steps ?? 1); i += 1) stepExchange(s);
375
+ save(a.exchange_file, s);
376
+ const last = s.history.at(-1);
377
+ return { step: s.step, prices: last.prices, spread: last.spread, volume: last.volume, forced_volume: last.forced_volume, tape_hash: s.tape_hash, plain_reading: `Paper step ${s.step}. ${s.joined.length ? "Joined agents act again before the next step." : ""}`.trim() };
378
+ });
379
+ }
380
+
381
+ // ---------------------------------------------------------------------------------------------
382
+ // What happened
383
+ // ---------------------------------------------------------------------------------------------
384
+ const pct = (x) => Number((x * 100).toFixed(2));
385
+ export function report(s) {
386
+ const assets = s.assets.map((asset) => {
387
+ const series = s.history.map((h) => h.prices[asset.id]).filter(Boolean);
388
+ let peak = series[0] ?? asset.last, worst = 0, troughAt = 0, peakAt = 0, peakOfWorst = 0;
389
+ series.forEach((p, i) => { if (p > peak) { peak = p; peakAt = i; } const d = p / peak - 1; if (d < worst) { worst = d; troughAt = i; peakOfWorst = peakAt; } });
390
+ const pre = series[peakOfWorst];
391
+ const back = worst < 0 ? series.slice(troughAt).findIndex((p) => p >= pre * 0.95) : -1;
392
+ const spreads = s.history.map((h) => h.spread[asset.id]).filter((x) => x != null).sort((x, y) => x - y);
393
+ const depth = s.history.map((h) => h.depth[asset.id]);
394
+ return {
395
+ asset: asset.id, last: asset.last, true_value: asset.value, worst_fall_percent: pct(worst), worst_fall_from_step: s.history[peakOfWorst]?.step ?? null, worst_fall_to_step: s.history[troughAt]?.step ?? null,
396
+ steps_to_recover_to_within_5_percent: worst < 0 ? (back >= 0 ? back : "not within this run") : null,
397
+ median_spread: spreads[Math.floor(spreads.length / 2)] ?? null, widest_spread: spreads.at(-1) ?? null,
398
+ thinnest_book: depth.length ? Math.min(...depth) : null, usual_book: depth.length ? [...depth].sort((x, y) => x - y)[Math.floor(depth.length / 2)] : null,
399
+ };
400
+ });
401
+ const groups = {};
402
+ for (const [name, me] of Object.entries(s.agents)) {
403
+ const key = me.persona === "agent" ? name : me.persona;
404
+ const g = groups[key] ??= { agents: 0, gain: 0, forced_sales: 0, trades: 0, ...(me.about ? { about: me.about } : {}) };
405
+ g.agents += 1; g.gain += worth(s, me) - me.start; g.forced_sales += me.forced; g.trades += me.trades;
406
+ }
407
+ const forcedVolume = s.history.reduce((n, h) => n + h.forced_volume, 0), volume = s.history.reduce((n, h) => n + Object.values(h.volume).reduce((x, y) => x + y, 0), 0);
408
+ return {
409
+ steps: s.step, seed: s.seed, trades: s.tape_count, tape_hash: s.tape_hash, assets,
410
+ who: { columns: ["who", "agents", "gain", "gain_per_agent", "forced_sales", "trades", "about"], rows: Object.entries(groups).map(([k, g]) => [k, g.agents, cents(g.gain), cents(g.gain / g.agents), g.forced_sales, g.trades, g.about ?? null]).sort((x, y) => y[3] - x[3]) },
411
+ cascades: { forced_share_of_volume_percent: volume ? pct(forcedVolume / volume) : 0, accounts_forced_at_the_worst_step: Math.max(0, ...s.history.map((h) => h.forced_accounts)), longest_chain_in_one_step: Math.max(0, ...s.history.map((h) => h.cascade_rounds)), steps_with_forced_sales: s.history.filter((h) => h.forced_volume > 0).length },
412
+ orders_refused_by_the_margin_rule: s.rejected,
413
+ how_to_check: "The tape_hash chains every trade. Rerun with the same seed, personas, shocks and joined agents' orders and the hash must match.",
414
+ limits: EXCHANGE_LIMITS_TEXT,
415
+ };
416
+ }
417
+ export function exchangeReport(args) {
418
+ const a = exchangeReportInput.parse(args);
419
+ return report(load(a.exchange_file));
420
+ }
421
+
422
+ // One whole run with only scripted personas, in one call: for comparing set-ups.
423
+ export const exchangeRunInput = exchangeNewInput.omit({ exchange_file: true }).extend({ steps: z.number().int().min(1).max(20000).describe("How many steps to run.") }).strict();
424
+ // About ten seconds of work: one call cannot hold a process, or the hosted server, for long.
425
+ const MOST_WORK = 3e6, MOST_MS = 20000;
426
+ export function exchangeRun(args) {
427
+ const { steps, ...rest } = exchangeRunInput.parse(args);
428
+ const s = createExchange(rest);
429
+ const work = steps * Object.keys(s.agents).length * s.assets.length;
430
+ if (work > MOST_WORK) throw new RangeError(`That is ${work.toLocaleString("en-US")} agent-steps, and one call does at most ${MOST_WORK.toLocaleString("en-US")}. Use fewer steps, agents or assets.`);
431
+ // Steps with deep books cost more than the count above can see, so time is watched too. A run this
432
+ // slow fails whole: a part of a run would not match anyone's replay.
433
+ const began = Date.now();
434
+ for (let i = 0; i < steps; i += 1) {
435
+ stepExchange(s);
436
+ if (Date.now() - began > MOST_MS) throw new RangeError(`This run was still going at step ${s.step} after ${MOST_MS / 1000} seconds. Use fewer steps, agents or assets.`);
437
+ }
438
+ return report(s);
439
+ }