canli-backtest-mcp 0.4.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/README.md +3 -0
- package/docs/AGENT_EXCHANGE.md +45 -0
- package/package.json +2 -1
- package/src/arena.mjs +1 -1
- package/src/consensus.mjs +89 -0
- package/src/diversifiers.mjs +69 -0
- package/src/exchange.mjs +439 -0
- package/src/server.mjs +21 -0
package/README.md
CHANGED
|
@@ -53,6 +53,9 @@ A backtest that assumes any size fills at the close flatters itself, so:
|
|
|
53
53
|
- **Proof ledger.** `proof_filings` writes what companies filed into an append-only file, each line chained to the last and each batch signed, with a line for every figure a later filing changed. `proof_file` adds any file's hash, `proof_verify` checks the whole file and says which line first fails. It shows lines were not altered, not that a figure is right, and no record means only no record. Publish the head somewhere else, or the key holder could rewrite it all. The [published ledger](https://github.com/arhancanli/canlicapital/releases) does that with a public attestation each month.
|
|
54
54
|
- **League.** `league_new`, `league_commit` and `league_score` run a tournament scored after the fact: an entrant signs and commits a hash before a round closes and shows the file afterwards, and anyone with the files can rerun the table. Each record is set against the best luck gives across that many entrants. The top entrants, chosen on earlier rounds only, form a paper book. No prizes, no stakes, nobody's money.
|
|
55
55
|
- **Arena.** `arena_new`, `arena_orders` and `arena_step` are a paper market for agents to trade in, cleared in a call auction, reproducible from a seed. Made-up assets and made-up money: it cannot reach anything real.
|
|
56
|
+
- **Agent exchange.** `exchange_new`, `exchange_join`, `exchange_view`, `exchange_orders`, `exchange_step` and `exchange_report` are a closed paper market where different AI agents and scripted traders meet in limit order books, with margin rules and forced selling, so a shock can become a cascade. `exchange_run` runs a whole market of scripted traders in one call. Time moves in steps, each agent sees only its own account, and a hash of the tape lets anyone check a run. Made-up assets and money. The rules are in [docs/AGENT_EXCHANGE.md](docs/AGENT_EXCHANGE.md).
|
|
57
|
+
- **Consensus.** `agent_consensus` combines agents' probabilities and counts how many independent views they really hold, from how their errors move together: five models that share their mistakes are not five votes.
|
|
58
|
+
- **Diversifiers.** `find_diversifiers` ranks candidates by the independent bets they add and how they behave on the book's worst days. Return is shown last and never used to rank.
|
|
56
59
|
|
|
57
60
|
## Quick start
|
|
58
61
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# The agent exchange protocol
|
|
2
|
+
|
|
3
|
+
Version 1. How different AI agents trade against each other in a closed paper market, whatever model or host each one runs on. Everything is made-up assets and made-up money. Nothing here can reach a real security, token or currency, and running a real market like this would need registration as an exchange or a broker-dealer.
|
|
4
|
+
|
|
5
|
+
## What it is for
|
|
6
|
+
|
|
7
|
+
Seeing what agents do together: who supplies liquidity, who runs when it goes, and how forced selling feeds on itself. It shows how a set of rules can behave. It does not predict any real market.
|
|
8
|
+
|
|
9
|
+
## The rules
|
|
10
|
+
|
|
11
|
+
**Time moves in steps.** Nobody wins by being on a faster machine. Every agent acts on the same view of the market, and a step closes only when each joined agent has acted, an empty list counting as passing. Within a step, the order agents act in comes from the seed.
|
|
12
|
+
|
|
13
|
+
**Each agent sees only its own account.** A view holds the book, recent trades without names, and the agent's own cash, positions and orders. It holds no other agent's positions and no asset's true value.
|
|
14
|
+
|
|
15
|
+
**An agent acts only as itself.** Joining gives a token, and every later call needs it.
|
|
16
|
+
|
|
17
|
+
**The book goes by price, then time.** A trade happens at the resting order's price. An order never trades with its own agent: the resting one is cancelled.
|
|
18
|
+
|
|
19
|
+
**Margin.** Positions may reach `leverage` times equity when opened. An account whose equity falls below `maintenance_margin` of its gross positions is sold down at market, half at a time, into whatever is left in the book. Each sale moves the price, which can put the next account under. That chain is the cascade, and the report counts its rounds.
|
|
20
|
+
|
|
21
|
+
**Anyone can check a run.** Every trade extends a hash chain, so the same seed, personas, shocks and orders give the same `tape_hash` on any machine.
|
|
22
|
+
|
|
23
|
+
## The messages
|
|
24
|
+
|
|
25
|
+
Each is a tool. All take `exchange_file`, the shared state file, and any number of servers on any number of hosts may use one file: writers take turns.
|
|
26
|
+
|
|
27
|
+
| Tool | Who | What |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `exchange_new` | Whoever sets it up | Seed, assets, personas, leverage, margin, and shocks at set steps |
|
|
30
|
+
| `exchange_join` | Each agent | Name and what it is. Returns its token |
|
|
31
|
+
| `exchange_view` | Each agent | The market as this agent may see it |
|
|
32
|
+
| `exchange_orders` | Each agent | `limit`, `market` and `cancel` orders for the coming step |
|
|
33
|
+
| `exchange_step` | Whoever runs the clock | Closes the step once every joined agent has acted |
|
|
34
|
+
| `exchange_report` | Anyone | Each asset's worst fall and recovery, spreads and depth, who gained and lost, forced selling, and the tape hash |
|
|
35
|
+
| `exchange_run` | Anyone | A whole run of scripted personas in one call, for comparing set-ups |
|
|
36
|
+
|
|
37
|
+
## Personas
|
|
38
|
+
|
|
39
|
+
Scripted traders fill out the market, each seeing what any agent sees. They are simple on purpose, and their numbers are in `src/exchange.mjs` because they are the assumptions: `market_maker` (quotes both sides and steps back when prices move fast), `value` (trades toward a noisy view of true worth), `momentum` (follows recent moves), `noise` (small random orders), `deleverager` (keeps its risk level, so it sells as volatility rises), `stop_loss` (sells after a fall), `levered_long` (borrows to the limit and holds until the margin rule sells it out).
|
|
40
|
+
|
|
41
|
+
Different parameters give different cascades. A result here is a question to ask of a real system, not an answer about it.
|
|
42
|
+
|
|
43
|
+
## Running different models together
|
|
44
|
+
|
|
45
|
+
`recipes/exchange_agents.mjs` runs each agent through its own server against any OpenAI-compatible endpoint, so one can be Claude and another GPT or a local model.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canli-backtest-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "MCP server for backtests that refuse lookahead: point-in-time factors from SEC filings, cross-sectional backtests of any signal on your own prices, costs on actual trades, and every variant counted in a trial ledger.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"src",
|
|
16
|
+
"docs",
|
|
16
17
|
"README.md"
|
|
17
18
|
],
|
|
18
19
|
"scripts": {
|
package/src/arena.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export const arenaOrdersInput = z.object({
|
|
|
32
32
|
export const arenaStepInput = z.object({ arena_file: file, steps: z.number().int().min(1).max(1000).optional().describe("Default 1.") }).strict();
|
|
33
33
|
|
|
34
34
|
const SHARES_PER_AGENT = 100;
|
|
35
|
-
const load = (f) => { if (!existsSync(f)) throw new RangeError(`${f} is not an arena yet: call arena_new`);
|
|
35
|
+
const load = (f) => { if (!existsSync(f)) throw new RangeError(`${f} is not an arena yet: call arena_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.arena.v1") throw new RangeError(`${f} is not an arena file`); return s; };
|
|
36
36
|
// Every order in a step comes from an agent in the file, however the file was written.
|
|
37
37
|
const orderLines = (state) => Object.entries(state.orders).filter(([agent]) => Object.hasOwn(state.agents, agent));
|
|
38
38
|
const save = (f, s) => writeFileSync(f, JSON.stringify(s));
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Consensus among agents, counted honestly. Five models that agree are not five votes if they share
|
|
2
|
+
// their training and their mistakes. So this measures how many independent views a group really holds,
|
|
3
|
+
// from how their errors move together, and weights each by its record. Agreement among copies adds
|
|
4
|
+
// nothing, and the answer says so.
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
const prob = z.number().min(0).max(1);
|
|
8
|
+
export const consensusInput = z.object({
|
|
9
|
+
agents: z.array(z.object({
|
|
10
|
+
name: z.string().min(1).max(60),
|
|
11
|
+
forecasts: z.array(z.object({ question: z.string().min(1).max(200), probability: prob }).strict()).min(1).max(5000),
|
|
12
|
+
}).strict()).min(2).max(200).describe("Each agent's probability for each question."),
|
|
13
|
+
outcomes: z.array(z.object({ question: z.string().min(1).max(200), happened: z.boolean() }).strict()).max(5000).optional().describe("Questions already settled. Their forecasts give each agent a record; the rest are combined."),
|
|
14
|
+
}).strict();
|
|
15
|
+
|
|
16
|
+
const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
17
|
+
const round = (x, d = 3) => (x == null || !Number.isFinite(x) ? null : Number(x.toFixed(d)));
|
|
18
|
+
const clip = (p) => Math.min(0.99, Math.max(0.01, p));
|
|
19
|
+
const logit = (p) => Math.log(clip(p) / (1 - clip(p)));
|
|
20
|
+
const unlogit = (x) => 1 / (1 + Math.exp(-x));
|
|
21
|
+
|
|
22
|
+
export function correlation(a, b) {
|
|
23
|
+
const ma = mean(a), mb = mean(b);
|
|
24
|
+
let up = 0, da = 0, db = 0;
|
|
25
|
+
for (let i = 0; i < a.length; i += 1) { up += (a[i] - ma) * (b[i] - mb); da += (a[i] - ma) ** 2; db += (b[i] - mb) ** 2; }
|
|
26
|
+
return da > 1e-12 && db > 1e-12 ? up / Math.sqrt(da * db) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// k for unrelated views, 1 for copies: (sum of eigenvalues)^2 / sum of squared eigenvalues of the
|
|
30
|
+
// correlation matrix. A pair with no measurable spread counts as the same view.
|
|
31
|
+
export function independentViews(series) {
|
|
32
|
+
const k = series.length;
|
|
33
|
+
let squares = 0;
|
|
34
|
+
for (let i = 0; i < k; i += 1) for (let j = 0; j < k; j += 1) { const c = i === j ? 1 : correlation(series[i], series[j]) ?? 1; squares += c * c; }
|
|
35
|
+
return k * k / squares;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function agentConsensus(args) {
|
|
39
|
+
const a = consensusInput.parse(args);
|
|
40
|
+
const happened = new Map((a.outcomes ?? []).map((o) => [o.question, o.happened ? 1 : 0]));
|
|
41
|
+
const own = a.agents.map((x) => new Map(x.forecasts.map((f) => [f.question, f.probability])));
|
|
42
|
+
const allQuestions = [...new Set(a.agents.flatMap((x) => x.forecasts.map((f) => f.question)))];
|
|
43
|
+
const settled = allQuestions.filter((q) => happened.has(q) && own.every((m) => m.has(q)));
|
|
44
|
+
const open = allQuestions.filter((q) => !happened.has(q));
|
|
45
|
+
const base = settled.length ? mean(settled.map((q) => happened.get(q))) : null;
|
|
46
|
+
const baseBrier = base == null ? null : mean(settled.map((q) => (base - happened.get(q)) ** 2));
|
|
47
|
+
|
|
48
|
+
const records = a.agents.map((x, i) => {
|
|
49
|
+
const errors = settled.map((q) => own[i].get(q) - happened.get(q));
|
|
50
|
+
const brier = errors.length ? mean(errors.map((e) => e * e)) : null;
|
|
51
|
+
return { name: x.name, errors, brier };
|
|
52
|
+
});
|
|
53
|
+
// Weighted by record once there is one; a Brier score near chance earns little, and with fewer than
|
|
54
|
+
// ten settled questions everyone counts the same.
|
|
55
|
+
const useRecord = settled.length >= 10;
|
|
56
|
+
const raw = records.map((r) => (useRecord ? 1 / Math.max(r.brier, 0.01) : 1)), total = raw.reduce((x, y) => x + y, 0);
|
|
57
|
+
const weights = raw.map((w) => w / total);
|
|
58
|
+
|
|
59
|
+
const basis = settled.length >= 5 ? "their errors on settled questions, after each agent's usual error on that outcome" : "their forecasts, since few questions are settled";
|
|
60
|
+
const shared = allQuestions.filter((q) => own.every((m) => m.has(q)));
|
|
61
|
+
// Good forecasters' errors all lean the same way, because they are all right about the same outcomes.
|
|
62
|
+
// What shows shared thinking is what is left once each agent's usual error on that outcome is removed.
|
|
63
|
+
const residual = (errors) => {
|
|
64
|
+
const usual = (hit) => { const xs = errors.filter((_, i) => happened.get(settled[i]) === hit); return xs.length ? mean(xs) : 0; };
|
|
65
|
+
const yes = usual(1), no = usual(0);
|
|
66
|
+
return errors.map((e, i) => e - (happened.get(settled[i]) === 1 ? yes : no));
|
|
67
|
+
};
|
|
68
|
+
const series = settled.length >= 5 ? records.map((r) => residual(r.errors)) : own.map((m) => shared.map((q) => m.get(q)));
|
|
69
|
+
const views = series[0]?.length >= 3 ? independentViews(series) : null;
|
|
70
|
+
|
|
71
|
+
const rows = open.map((q) => {
|
|
72
|
+
const have = own.map((m, i) => [m.get(q), weights[i]]).filter(([p]) => p !== undefined);
|
|
73
|
+
const sum = have.reduce((n, [, w]) => n + w, 0);
|
|
74
|
+
const combined = unlogit(have.reduce((n, [p, w]) => n + logit(p) * w, 0) / sum);
|
|
75
|
+
const ps = have.map(([p]) => p);
|
|
76
|
+
return [q, have.length, round(combined), round(mean(ps)), round(Math.min(...ps)), round(Math.max(...ps))];
|
|
77
|
+
});
|
|
78
|
+
const k = a.agents.length;
|
|
79
|
+
return {
|
|
80
|
+
agents: k,
|
|
81
|
+
independent_views: round(views, 1),
|
|
82
|
+
independent_views_from: views == null ? "too few shared questions to measure" : basis,
|
|
83
|
+
reading: views == null ? "Too few shared questions to say how independent these agents are." : `These ${k} agents hold about ${round(views, 1)} independent ${views < 1.5 ? "view" : "views"} between them.${views < k / 2 ? " Most of their agreement is shared thinking, so it should not raise confidence the way separate opinions would." : ""}`,
|
|
84
|
+
records: { columns: ["agent", "settled_questions", "brier_score", "skill_against_base_rate", "weight"], rows: records.map((r, i) => [r.name, settled.length, round(r.brier), r.brier != null && baseBrier ? round(1 - r.brier / baseBrier) : null, round(weights[i])]) },
|
|
85
|
+
combined: { columns: ["question", "agents", "combined_probability", "plain_average", "lowest", "highest"], rows },
|
|
86
|
+
meaning: "A Brier score is the mean squared error of a probability (lower is better; always saying 50% scores 0.25). Skill above 0 beats always saying the base rate. The combined probability is a weighted average of log-odds.",
|
|
87
|
+
limits: ["With few settled questions the records and the count of views are noisy.", "A group's record says how it did on these questions. It does not say it will keep doing so."],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// What to add to a book, by correlation first. Most searches rank by return and check correlation
|
|
2
|
+
// afterwards. This one ranks by how much a candidate adds in independent bets and how it behaves on
|
|
3
|
+
// the book's worst days; return comes last, because a high return found by search is the least
|
|
4
|
+
// trustworthy number there is.
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
import { readPanel } from "./panel-io.mjs";
|
|
8
|
+
|
|
9
|
+
export const diversifiersInput = z.object({
|
|
10
|
+
book_file: z.string().min(1).max(1000).describe("A CSV of the book's daily returns: date, then one column per strategy or one for the whole book."),
|
|
11
|
+
candidates_file: z.string().min(1).max(1000).describe("A CSV of candidates' daily returns: date, then one column each."),
|
|
12
|
+
limit: z.number().int().min(1).max(100).optional().describe("How many to return; default 10."),
|
|
13
|
+
min_days: z.number().int().min(60).max(100000).optional().describe("Days a candidate must share with the book; default 252."),
|
|
14
|
+
}).strict();
|
|
15
|
+
|
|
16
|
+
const round = (x, d = 3) => (x == null || !Number.isFinite(x) ? null : Number(x.toFixed(d)));
|
|
17
|
+
function corr(a, b) {
|
|
18
|
+
const n = a.length;
|
|
19
|
+
if (n < 3) return null;
|
|
20
|
+
let ma = 0, mb = 0;
|
|
21
|
+
for (let i = 0; i < n; i += 1) { ma += a[i]; mb += b[i]; }
|
|
22
|
+
ma /= n; mb /= n;
|
|
23
|
+
let up = 0, da = 0, db = 0;
|
|
24
|
+
for (let i = 0; i < n; i += 1) { up += (a[i] - ma) * (b[i] - mb); da += (a[i] - ma) ** 2; db += (b[i] - mb) ** 2; }
|
|
25
|
+
return da > 1e-18 && db > 1e-18 ? up / Math.sqrt(da * db) : null;
|
|
26
|
+
}
|
|
27
|
+
const annualSharpe = (r) => {
|
|
28
|
+
const m = r.reduce((a, b) => a + b, 0) / r.length, v = r.reduce((a, b) => a + (b - m) ** 2, 0) / (r.length - 1);
|
|
29
|
+
return v > 0 ? m / Math.sqrt(v) * Math.sqrt(252) : null;
|
|
30
|
+
};
|
|
31
|
+
// k for unrelated series, 1 for copies.
|
|
32
|
+
function bets(series) {
|
|
33
|
+
const k = series.length;
|
|
34
|
+
let squares = 0;
|
|
35
|
+
for (let i = 0; i < k; i += 1) for (let j = 0; j < k; j += 1) { const c = i === j ? 1 : corr(series[i], series[j]) ?? 1; squares += c * c; }
|
|
36
|
+
return k * k / squares;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function findDiversifiers(args) {
|
|
40
|
+
const a = diversifiersInput.parse(args);
|
|
41
|
+
const book = readPanel(a.book_file, "book_file"), cand = readPanel(a.candidates_file, "candidates_file");
|
|
42
|
+
const minDays = a.min_days ?? 252;
|
|
43
|
+
const cDate = new Map(cand.dates.map((d, i) => [d, i]));
|
|
44
|
+
const shared = book.dates.map((d, i) => [i, cDate.get(d)]).filter(([, j]) => j !== undefined);
|
|
45
|
+
if (shared.length < minDays) throw new RangeError(`The two files share ${shared.length} days and at least ${minDays} are needed.`);
|
|
46
|
+
const rows = [];
|
|
47
|
+
for (const name of cand.tickers) {
|
|
48
|
+
const pairs = shared.filter(([i, j]) => cand.values[name][j] !== null && book.tickers.every((t) => book.values[t][i] !== null));
|
|
49
|
+
if (pairs.length < minDays) continue;
|
|
50
|
+
const bookCols = book.tickers.map((t) => pairs.map(([i]) => book.values[t][i]));
|
|
51
|
+
const whole = pairs.map((_, n) => bookCols.reduce((s, c) => s + c[n], 0) / bookCols.length);
|
|
52
|
+
const c = pairs.map(([, j]) => cand.values[name][j]);
|
|
53
|
+
const worst = [...whole.keys()].sort((x, y) => whole[x] - whole[y]).slice(0, Math.max(20, Math.floor(whole.length / 10)));
|
|
54
|
+
const onWorst = worst.reduce((s, i) => s + c[i], 0) / worst.length;
|
|
55
|
+
const before = bets(bookCols), after = bets([...bookCols, c]);
|
|
56
|
+
rows.push({ name, days: pairs.length, correlation: corr(whole, c), worst_days_correlation: corr(worst.map((i) => whole[i]), worst.map((i) => c[i])), mean_on_worst_days_percent: onWorst * 100, bets_added: after - before, sharpe: annualSharpe(c) });
|
|
57
|
+
}
|
|
58
|
+
if (!rows.length) throw new RangeError(`No candidate shares ${minDays} days with the whole book.`);
|
|
59
|
+
// More independent bets first; among near ties, what it does when the book is at its worst.
|
|
60
|
+
rows.sort((x, y) => (Math.round((y.bets_added - x.bets_added) * 20) / 20) || ((x.worst_days_correlation ?? 1) - (y.worst_days_correlation ?? 1)));
|
|
61
|
+
const shown = rows.slice(0, a.limit ?? 10);
|
|
62
|
+
return {
|
|
63
|
+
book_series: book.tickers.length, candidates_read: rows.length,
|
|
64
|
+
columns: ["candidate", "days", "correlation_with_book", "correlation_on_books_worst_days", "mean_on_books_worst_days_percent", "independent_bets_added", "sharpe_alone"],
|
|
65
|
+
rows: shown.map((r) => [r.name, r.days, round(r.correlation), round(r.worst_days_correlation), round(r.mean_on_worst_days_percent), round(r.bets_added), round(r.sharpe, 2)]),
|
|
66
|
+
meaning: "independent_bets_added is how much the candidate raises the participation ratio of the correlations (1.0 for something wholly unrelated to everything in the book). The worst days are the book's worst tenth. Sharpe is shown last and is not used to rank.",
|
|
67
|
+
limits: [`${rows.length} candidates were compared, so the best of them looks better than it is: judge any one with validate_luck_trials at that many trials.`, "Correlations move, and they tend to rise in a crisis. These are from the days in the files."],
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/exchange.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/server.mjs
CHANGED
|
@@ -25,6 +25,9 @@ import { buildPanel, panelInput } from "./panel.mjs";
|
|
|
25
25
|
import { CRISES, crisisReplay, ENGINE_LIMITS_TEXT, describe, runBacktest } from "./engine.mjs";
|
|
26
26
|
import { cpcv, cpcvInput, CPCV_LIMITS_TEXT } from "./cpcv.mjs";
|
|
27
27
|
import { arenaNew, arenaNewInput, arenaOrders, arenaOrdersInput, arenaStep, arenaStepInput } from "./arena.mjs";
|
|
28
|
+
import { exchangeJoin, exchangeJoinInput, exchangeNew, exchangeNewInput, exchangeOrders, exchangeOrdersInput, exchangeReport, exchangeReportInput, exchangeRun, exchangeRunInput, exchangeStep, exchangeStepInput, exchangeView, exchangeViewInput } from "./exchange.mjs";
|
|
29
|
+
import { agentConsensus, consensusInput } from "./consensus.mjs";
|
|
30
|
+
import { diversifiersInput, findDiversifiers } from "./diversifiers.mjs";
|
|
28
31
|
import { leagueCommit, leagueCommitInput, leagueNew, leagueNewInput, leagueScore, leagueScoreInput } from "./league.mjs";
|
|
29
32
|
import { proofFile, proofFileInput, proofFilings, proofFilingsInput, proofFind, proofFindInput, proofVerifyInput, verifyLedger } from "./proof.mjs";
|
|
30
33
|
import { readPanel, writePanel } from "./panel-io.mjs";
|
|
@@ -135,6 +138,15 @@ export const TOOL_DESCRIPTIONS = Object.freeze({
|
|
|
135
138
|
league_new: "Start a paper league: a schedule of rounds and rules with a hash to publish. No prizes, no stakes and nobody's money.",
|
|
136
139
|
league_commit: "Commit a league entry before its round closes: only the hash of its CSV of ticker,score goes in the ledger, and the file is shown after the round. Only the latest commit counts, so revealing whichever of several did best is refused.",
|
|
137
140
|
league_score: "Score a paper league from its files and prices, repeatable by anyone: each entrant's rank correlation with what followed, set against the best luck gives across that many entrants, how different each is from the rest, and a paper book of the top entrants chosen on earlier rounds only.",
|
|
141
|
+
exchange_new: "Start a paper exchange where different AI agents and scripted traders (market makers, momentum, value, deleveragers, stop-losses, levered longs) trade in limit order books, with margin rules and forced selling, plus shocks at set steps. Made-up assets and money, reproducible from a seed.",
|
|
142
|
+
exchange_join: "Join a paper exchange as an agent. Returns the token its other calls need, so no agent can act as another.",
|
|
143
|
+
exchange_view: "What an agent in a paper exchange can see: the books, recent trades and its own account. No other agent's positions.",
|
|
144
|
+
exchange_orders: "Send an agent's limit orders, market orders and cancels for the coming step of a paper exchange. An empty list passes.",
|
|
145
|
+
exchange_step: "Move a paper exchange on. Waits for every joined agent to act, so nobody moves time on the others.",
|
|
146
|
+
exchange_report: "What happened in a paper exchange: each asset's worst fall and recovery, spreads and depth, who gained and lost, forced selling and the longest chain of it, and a hash anyone can check by replaying.",
|
|
147
|
+
exchange_run: "Run a whole paper exchange of scripted traders in one call and report it: for comparing set-ups, such as how much leverage turns a shock into a cascade. A simulation, not a forecast.",
|
|
148
|
+
agent_consensus: "Combine several AI agents' probabilities, and count how many independent views they really hold, from how their errors move together: five models that share their mistakes are not five votes. Weights each by its record on settled questions.",
|
|
149
|
+
find_diversifiers: "Rank candidate strategies by what they add to a book: independent bets added first, then how they behave on the book's worst days, with return shown last and never used to rank. Correlation first.",
|
|
138
150
|
arena_new: "Start a paper market for AI agents to trade in: made-up assets and made-up money, with scripted traders, reproducible from a seed. Nothing here represents or reaches a real security, token or currency.",
|
|
139
151
|
arena_orders: "Send paper orders for an agent in an arena, for the coming auction. No short selling and no borrowing.",
|
|
140
152
|
arena_step: "Clear a paper arena's next call auctions at one price per asset, and show prices and standings. Paper only.",
|
|
@@ -291,6 +303,15 @@ export function registerTools(server, session) {
|
|
|
291
303
|
tool("league_new", "Start a paper league", leagueNewInput, WRITES_LOCAL, async (args) => text(leagueNew(args)));
|
|
292
304
|
tool("league_commit", "Commit a league entry", leagueCommitInput, WRITES_LOCAL, async (args) => text(leagueCommit(args)));
|
|
293
305
|
tool("league_score", "Score a paper league", leagueScoreInput, READ_ONLY, async (args) => text(leagueScore(args)));
|
|
306
|
+
tool("exchange_new", "Start a paper exchange", exchangeNewInput, WRITES_LOCAL, async (args) => text(exchangeNew(args)));
|
|
307
|
+
tool("exchange_join", "Join a paper exchange", exchangeJoinInput, WRITES_LOCAL, async (args) => text(exchangeJoin(args)));
|
|
308
|
+
tool("exchange_view", "See a paper exchange", exchangeViewInput, READ_ONLY, async (args) => text(exchangeView(args)));
|
|
309
|
+
tool("exchange_orders", "Send orders to a paper exchange", exchangeOrdersInput, WRITES_LOCAL, async (args) => text(exchangeOrders(args)));
|
|
310
|
+
tool("exchange_step", "Move a paper exchange on", exchangeStepInput, WRITES_LOCAL, async (args) => text(exchangeStep(args)));
|
|
311
|
+
tool("exchange_report", "Report a paper exchange", exchangeReportInput, READ_ONLY, async (args) => text(exchangeReport(args)));
|
|
312
|
+
tool("exchange_run", "Run a whole paper exchange", exchangeRunInput, READ_ONLY, async (args) => text(exchangeRun(args)));
|
|
313
|
+
tool("agent_consensus", "Combine agents' forecasts and count independent views", consensusInput, READ_ONLY, async (args) => text(agentConsensus(args)));
|
|
314
|
+
tool("find_diversifiers", "Find what diversifies a book", diversifiersInput, READ_ONLY, async (args) => text(findDiversifiers(args)));
|
|
294
315
|
tool("arena_new", "Start a paper arena", arenaNewInput, WRITES_LOCAL, async (args) => text(arenaNew(args)));
|
|
295
316
|
tool("arena_orders", "Send paper orders to an arena", arenaOrdersInput, WRITES_LOCAL, async (args) => text(arenaOrders(args)));
|
|
296
317
|
tool("arena_step", "Clear a paper arena", arenaStepInput, WRITES_LOCAL, async (args) => text(arenaStep(args)));
|