SPB Git

Repository Public

agent-universe.test.ts 26.6 KB 257 lines typescript

1// V5, pure parts: agents on any execution-enabled network, universes of up to 12 market symbols, a cash leg, the gas2// reserve per network, mandates per network, universe-generic strategies, the registry-derived market symbol set.3import { beforeAll, describe, expect, it } from 'vitest';4import { cashLegs, DEFAULT_POLICY, getNetwork, hasMarketData, MANDATE_NETWORKS, parseUnits, tradableSymbols, type AssetDef, type NetworkDef } from '@spw/core';5import type { AgentRow } from '../src/agents/routes.ts';6import type { Holding } from '../src/agents/engine.ts';78let engine: typeof import('../src/agents/engine.ts'), routes: typeof import('../src/agents/routes.ts'), gas: typeof import('../src/agents/gas.ts'), strategies: typeof import('../src/agents/strategies.ts'),9  symbols: typeof import('../src/market/symbols.ts'), pipeline: typeof import('../src/market/pipeline.ts');10beforeAll(async () => {11  Object.assign(process.env, { SPW_ORIGIN: 'https://wallet.test', SPW_RP_ID: 'wallet.test', DATABASE_URL: 'postgres:///unused', SPW_SIGNER_SECRET: 'k'.repeat(48), SPW_WORKERS: 'off' });12  engine = await import('../src/agents/engine.ts'); routes = await import('../src/agents/routes.ts'); gas = await import('../src/agents/gas.ts'); strategies = await import('../src/agents/strategies.ts');13  symbols = await import('../src/market/symbols.ts'); pipeline = await import('../src/market/pipeline.ts');14});15const RISK = { maxExposurePct: 100, maxLossPct: 5, maxOrderUsd: 25, maxDailyUsd: 1000, maxOrdersPerDay: 50, mandateDays: 30, slippageBps: 50 };16const agent = (o: Partial<AgentRow> = {}) => ({ id: 'a', name: 'Any', network: 'base', universe: ['ETH', 'LINK'], cash: null, assets: null, risk: RISK, vault_index: 3, vault_address: '0x' + '11'.repeat(20), ...o }) as unknown as AgentRow;17const TWELVE = ['AAVE', 'AVAX', 'BNB', 'BTC', 'DOGE', 'ETH', 'LINK', 'LTC', 'POL', 'SOL', 'UNI', 'XRP'];18const tok = (symbol: string, k: number, decimals = 18): AssetDef => ({ id: `0x${k.toString(16).padStart(40, '0')}`, symbol, name: symbol, decimals });19/** A FIXED registry for one network: only the named tokens of today's registry, plus injected ones (stable when the real one grows). */20const fixed = (id: string, keep: string[], extra: AssetDef[] = []): NetworkDef => { const n = getNetwork(id); return { ...n, tokens: [...keep.map((x) => { const t = n.tokens.find((y) => y.symbol === x); if (!t) throw new Error(`${x} is not on ${id}`); return t; }), ...extra] }; };2122describe('planning with many symbols', () => {23  const px = Object.fromEntries(TWELVE.map((s, k) => [s, 10 * (k + 1)]));24  const hold = (cash: number, units: Record<string, number> = {}, cashSymbol = 'USDC'): Holding[] => [{ symbol: cashSymbol, id: '0xcash', decimals: 6, units: cash }, ...TWELVE.map((s, k) => ({ symbol: s, id: `0x${k}`, decimals: k % 2 ? 8 : 18, units: units[s] ?? 0 }))];25  it('validates weights for 12 symbols and refuses a 13th', () => {26    const w = Object.fromEntries(TWELVE.map((s) => [s, 1 / 12]));27    expect(Object.keys(engine.checkWeights(w, TWELVE))).toHaveLength(12);28    expect(() => engine.checkWeights({ ...w, ADA: 0.01 }, TWELVE)).toThrow(/outside the agent's universe/);29    expect(() => engine.checkWeights(Object.fromEntries(TWELVE.map((s) => [s, 0.09])), TWELVE)).toThrow(/more than 1/);30    expect(engine.checkWeights({ ETH: 0.5, USDT: 0.5 }, ['ETH'], 'USDT')).toEqual({ ETH: 0.5 }); // the cash leg is not a weight31    expect(() => engine.checkWeights({ ETH: 0.5, USDC: 0.5 }, ['ETH'], 'USDT')).toThrow(/outside/);32  });33  it('walks a 12-symbol portfolio to its target one order at a time: sells first, then the largest buy', () => {34    const a = agent({ universe: TWELVE }), w = Object.fromEntries(TWELVE.map((s) => [s, 1 / 12])); // 1 200 USD → 100 USD each35    let h = hold(1200), orders = 0;36    for (; orders < 60; orders++) { // order cap 25 USD: four orders per symbol37      const p = engine.planOrder(a, w, h, px, 0, 0);38      if (!p.order) { expect(p.note).toBe('portfolio already at target'); break; }39      expect(p.order.fromSymbol).toBe('USDC'); expect(p.order.usd).toBeLessThanOrEqual(25);40      const usd = Number(p.order.amountIn) / 1e6, to = p.order.toSymbol;41      h = h.map((x) => (x.symbol === 'USDC' ? { ...x, units: x.units - usd } : x.symbol === to ? { ...x, units: x.units + usd / px[to]! } : x));42    }43    expect(orders).toBe(48);44    for (const s of TWELVE) expect(h.find((x) => x.symbol === s)!.units * px[s]!).toBeCloseTo(100, 6);45    expect(h[0]!.units).toBeCloseTo(0, 6);46    // one symbol far above its target and eleven below: the sale comes first, sized by the order cap, in the asset's own decimals47    const heavy = engine.planOrder(a, w, hold(0, { BTC: 30 }), px, 0, 0); // 30 BTC at 40 = 1 200 USD, target 10048    expect(heavy.order).toMatchObject({ fromSymbol: 'BTC', toSymbol: 'USDC', fromDecimals: 8, amountIn: 62_500_000n }); // 25 USD / 40 = 0.625 BTC49    expect(engine.planOrder(a, Object.fromEntries(TWELVE.map((s) => [s, 0])), hold(0, { XRP: 1 }), px, 0, 0).order).toMatchObject({ fromSymbol: 'XRP', toSymbol: 'USDC' });50  });51  it('trades against the cash leg the owner chose', () => {52    const a = agent({ universe: ['AVAX'], cash: 'USDT', network: 'avalanche' });53    const buy = engine.planOrder(a, { AVAX: 0.5 }, [{ symbol: 'USDT', id: '0xt', decimals: 6, units: 100 }, { symbol: 'AVAX', id: 'native', decimals: 18, units: 0 }], { AVAX: 20 }, 0, 0);54    expect(buy.order).toMatchObject({ fromSymbol: 'USDT', toSymbol: 'AVAX', fromId: '0xt', amountIn: 25_000000n });55    const sell = engine.planOrder(a, { AVAX: 0 }, [{ symbol: 'USDT', id: '0xt', decimals: 6, units: 0 }, { symbol: 'AVAX', id: 'native', decimals: 18, units: 1 }], { AVAX: 20 }, 0, 0);56    expect(sell.order).toMatchObject({ fromSymbol: 'AVAX', toSymbol: 'USDT', toId: '0xt' });57    // a USDC balance is not this agent's cash58    expect(engine.planOrder(a, { AVAX: 0.5 }, [{ symbol: 'USDC', id: '0xc', decimals: 6, units: 100 }, { symbol: 'AVAX', id: 'native', decimals: 18, units: 0 }], { AVAX: 20 }, 0, 0).order).toBeNull();59  });60  it('sizes every order in the decimals of the asset given up: an 18-decimal cash leg (BNB Smart Chain) like a 6-decimal one', () => {61    // the same portfolio on every execution-enabled network, with that network's own cash leg and native coin from the registry62    for (const id of MANDATE_NETWORKS) { const n = getNetwork(id), sym = n.native.symbol, a = agent({ network: id, universe: [sym] });63      const [cash, coin] = routes.agentAssets(a), held = (c: number, u: number): Holding[] => [{ symbol: cash!.symbol, id: cash!.id, decimals: cash!.decimals, units: c }, { symbol: coin!.symbol, id: coin!.id, decimals: coin!.decimals, units: u }];64      expect(cash!.decimals, id).toBe(cashLegs(n)[0]!.decimals);65      const buy = engine.planOrder(a, { [sym]: 0.5 }, held(100, 0), { [sym]: 50 }, 0, 0).order!; // target 50 USD, order cap 25 USD66      expect(buy, id).toMatchObject({ fromSymbol: 'USDC', toSymbol: sym, fromId: cash!.id, fromDecimals: cash!.decimals, toDecimals: 18, usd: 25 });67      expect(buy.amountIn, id).toBe(25n * 10n ** BigInt(cash!.decimals)); // 25 USDC: 25000000 in 6 decimals, 25000000000000000000 in 1868      const sell = engine.planOrder(a, { [sym]: 0 }, held(0, 3), { [sym]: 50 }, 0, 0).order!; // 150 USD of the coin, order cap 25 USD → 0.5 coin69      expect(sell, id).toMatchObject({ fromSymbol: sym, toSymbol: 'USDC', toId: cash!.id, toDecimals: cash!.decimals, amountIn: 500_000_000_000_000_000n });70      const odd = engine.planOrder(a, { [sym]: 1 }, held(7.654321, 0), { [sym]: 50 }, 0, 0).order!; // buys with the cash actually held, to the micro-dollar71      expect(odd.amountIn, id).toBe(parseUnits('7.654321', cash!.decimals));72    }73    expect(cashLegs(getNetwork('bsc'))[0]!.decimals).toBe(18); // the case this test exists for74    // the allocated capital is a USD amount kept in 6-decimal units whatever the cash leg: the paper book starts from it in whole units75    expect(routes.agentAssets(agent({ network: 'bsc', universe: ['BNB', 'ETH', 'BTC'] })).map((x) => [x.symbol, x.assetSymbol, x.decimals])).toEqual([['USDC', 'USDC', 18], ['BNB', 'BNB', 18], ['ETH', 'ETH', 18], ['BTC', 'WBTC', 8]]);76  });77});7879describe('agent assets: one registry asset per market symbol, on the agent\'s network', () => {80  it('an agent created before V5 (no cash, no recorded choice) resolves exactly as before', () => {81    const base = getNetwork('base'), id = (s: string) => base.tokens.find((t) => t.symbol === s)!.id;82    expect(routes.agentAssets(agent({ universe: ['ETH', 'LINK', 'AAVE'] })).map((x) => [x.symbol, x.id, x.decimals])).toEqual([['USDC', id('USDC'), 6], ['ETH', 'native', 18], ['LINK', id('LINK'), 18], ['AAVE', id('AAVE'), 18]]);83    expect(routes.agentAssets(agent({ universe: ['USDC', 'ETH'] })).map((x) => x.symbol)).toEqual(['USDC', 'ETH']); // old rows could list the cash leg84    expect(routes.cashOf(agent())).toBe('USDC');85  });86  it('resolves per network, with the recorded representation, and reports the registry asset by name', () => {87    const eth = getNetwork('ethereum'), weth = eth.tokens.find((t) => t.symbol === 'WETH')!.id;88    const a = agent({ network: 'ethereum', universe: ['BTC', 'ETH', 'UNI'], assets: { ETH: weth } });89    expect(routes.agentAssets(a).map((x) => [x.symbol, x.assetSymbol, x.kind])).toEqual([['USDC', 'USDC', 'cash'], ['BTC', 'WBTC', 'wrapped'], ['ETH', 'WETH', 'wrapped'], ['UNI', 'UNI', 'token']]);90    expect(routes.agentAssets(agent({ network: 'arbitrum', universe: ['ETH', 'LINK', 'UNI', 'AAVE'] })).map((x) => x.id.length)).toEqual([42, 6, 42, 42, 42]);91    // a fixed small registry (Arbitrum with USDC, LINK and UNI only): what is not there is refused with a reason…92    const small = fixed('arbitrum', ['USDC', 'LINK', 'UNI']);93    expect(() => routes.agentAssets(agent({ network: 'arbitrum', universe: ['BTC'] }), small)).toThrow(/no asset of the wallet registry tracks BTC on Arbitrum One/);94    expect(() => routes.agentAssets(agent({ network: 'bsc', universe: ['BNB'] }), fixed('bsc', []))).toThrow(/no cash leg/);95    // …and when the registry grows, the same call picks the new asset up96    const grown = fixed('arbitrum', ['USDC', 'LINK', 'UNI'], [tok('cbBTC', 0xa1, 8), tok('ARB', 0xa2)]);97    expect(routes.agentAssets(agent({ network: 'arbitrum', universe: ['BTC', 'ARB'] }), grown).map((x) => x.assetSymbol)).toEqual(['USDC', 'cbBTC', 'ARB']);98    expect(tradableSymbols(grown).length).toBe(tradableSymbols(small).length + 2);99    // today's registry: bitcoin on Arbitrum is WBTC by default (reviewed rank 1) and cbBTC or tBTC by choice; on a real symbol that100    // no asset of the network tracks, the same refusal101    const arb = getNetwork('arbitrum');102    expect(routes.agentAssets(agent({ network: 'arbitrum', universe: ['BTC'] })).map((x) => [x.assetSymbol, x.decimals])).toEqual([['USDC', 6], ['WBTC', 8]]);103    expect(routes.agentAssets(agent({ network: 'arbitrum', universe: ['BTC'], assets: { BTC: arb.tokens.find((t) => t.symbol === 'tBTC')!.id } }))[1]).toMatchObject({ assetSymbol: 'tBTC', decimals: 18, issuer: expect.stringMatching(/Threshold/) });104    for (const s of ['SOL', 'XRP', 'DOGE'].filter((x) => hasMarketData(x) && !tradableSymbols(arb).some((t) => t.symbol === x)))105      expect(() => routes.agentAssets(agent({ network: 'arbitrum', universe: [s] })), s).toThrow(new RegExp(`no asset of the wallet registry tracks ${s} on Arbitrum One`));106    // BNB Smart Chain has a cash leg since registry v5: Binance-Peg USDC, 18 decimals107    expect(routes.agentAssets(agent({ network: 'bsc', universe: ['BNB'] })).map((x) => [x.symbol, x.name, x.kind, x.decimals])).toEqual([['USDC', 'Binance-Peg USD Coin', 'cash', 18], ['BNB', 'BNB', 'native', 18]]);108  });109});110111describe('gas reserve per network', () => {112  const fee = DEFAULT_POLICY.maxFee, gwei = (x: number) => BigInt(Math.round(x * 1e9));113  it('is sized from the recent gas price, capped per order by the signer fee ceiling, with a floor of two orders at that ceiling', () => {114    for (const id of MANDATE_NETWORKS) expect(fee[id], id).toBeDefined(); // every execution-enabled network has a ceiling in the default policy115    const base = gas.reserveFromGas(gwei(0.01), BigInt(fee.base!)); // 450 000 gas at 0.01 gwei = 0.0000045 ETH per order116    expect(base.perOrder).toBe(4_500_000_000_000n);117    expect(base.reserve).toBe(2n * BigInt(fee.base!)); // 8 orders = 0.000036 ETH: the floor (2 × ceiling) governs118    const busyBase = gas.reserveFromGas(gwei(0.5), BigInt(fee.base!)); // 0.000225 ETH per order119    expect(busyBase.reserve).toBe(8n * 225_000_000_000_000n);120    const eth = gas.reserveFromGas(gwei(3), BigInt(fee.ethereum!)); // 0.00135 ETH per order121    expect(eth.perOrder).toBe(1_350_000_000_000_000n); expect(eth.reserve).toBe(10_800_000_000_000_000n); // 0.0108 ETH122    const spike = gas.reserveFromGas(gwei(80), BigInt(fee.ethereum!)); // one order would cost 0.036 ETH: above the ceiling, the signer would refuse it123    expect(spike.perOrder).toBe(BigInt(fee.ethereum!)); expect(spike.reserve).toBe(8n * BigInt(fee.ethereum!));124    expect(gas.reserveFromGas(0n, BigInt(fee.polygon!)).reserve).toBe(2n * BigInt(fee.polygon!)); // never zero125    for (const id of MANDATE_NETWORKS) { const r = gas.reserveFromGas(gwei(1), BigInt(fee[id]!)); expect(r.reserve >= 2n * BigInt(fee[id]!) && r.reserve <= 8n * BigInt(fee[id]!), id).toBe(true); }126    expect(() => gas.reserveFromGas(gwei(1), 0n)).toThrow();127  });128  it('measures how much of an order the network fee eats: dollars on Ethereum, a fraction of a cent on a layer 2', () => {129    const eth = gas.reserveFromGas(gwei(3), BigInt(fee.ethereum!)), base = gas.reserveFromGas(gwei(0.01), BigInt(fee.base!));130    const onEth = gas.feeShare(eth.perOrder, 18, 2000, 25), onBase = gas.feeShare(base.perOrder, 18, 2000, 25);131    expect(onEth).toBeCloseTo(0.108, 6); // 2.70 USD on a 25 USD order132    expect(onEth).toBeGreaterThan(gas.FEE_WARN_SHARE); expect(onBase).toBeLessThan(gas.FEE_WARN_SHARE);133    expect(gas.feeShare(eth.perOrder, 18, 2000, 5000)).toBeLessThan(gas.FEE_WARN_SHARE); // a large order absorbs it134  });135  it('no fee ceiling in the policy, no reserve: live trading cannot start', async () => {136    await expect(gas.gasReserve(getNetwork('base'), undefined)).rejects.toThrow(/no fee ceiling/);137  });138});139140describe('mandate built for the agent\'s own network', () => {141  const now = Math.floor(Date.now() / 1000), px = { ETH: 2000, LINK: 10, UNI: 5, AAVE: 100, BTC: 60_000, AVAX: 20, POL: 0.5, HYPE: 25, EUR: 1.1 };142  it('pins network, vault and that network\'s asset ids, with caps in each asset\'s own decimals', () => {143    for (const id of MANDATE_NETWORKS) {144      const n = getNetwork(id);145      if (!cashLegs(n).length) { expect(() => routes.mandateFor(agent({ network: id, universe: [n.native.symbol] }), 'spw-0123456789abcdef', 'ab'.repeat(32), px, now), id).toThrow(/no cash leg/); continue; }146      const m = routes.mandateFor(agent({ network: id, universe: [n.native.symbol] }), 'spw-0123456789abcdef', 'ab'.repeat(32), { ...px, [n.native.symbol]: 50 }, now);147      expect(m.network, id).toBe(id); expect(m.vaultIndex).toBe(3);148      expect(m.assets, id).toEqual([cashLegs(n)[0]!.id, 'native']);149      const cash = cashLegs(n)[0]!; // 25 USD and 1 000 USD a day, in the cash leg's own decimals on that network150      expect(m.maxOrder[cash.id], id).toBe((25n * 10n ** BigInt(cash.decimals)).toString()); expect(m.maxDaily[cash.id], id).toBe((1000n * 10n ** BigInt(cash.decimals)).toString());151      expect(m.maxOrder.native, id).toBe('500000000000000000'); // 25 USD at 50 USD152      expect(m.maxSlippageBps).toBe(150); expect(m.expiresAt - m.createdAt).toBe(30 * 86400);153    }154    // pinned on purpose: 6 decimals on Base, 18 on BNB Smart Chain (Binance-Peg USDC) — the same 25 USD155    const capOn = (id: string) => { const n = getNetwork(id); return routes.mandateFor(agent({ network: id, universe: [n.native.symbol] }), 'spw-0123456789abcdef', 'ab'.repeat(32), { [n.native.symbol]: 50 }, now).maxOrder[cashLegs(n)[0]!.id]; };156    expect(capOn('base')).toBe('25000000'); expect(capOn('bsc')).toBe('25000000000000000000');157    const usdt = routes.mandateFor(agent({ network: 'bsc', universe: ['BNB'], cash: 'USDT' }), 'spw-0123456789abcdef', 'ab'.repeat(32), { BNB: 50 }, now);158    expect(usdt.assets[0]).toBe(cashLegs(getNetwork('bsc')).find((c) => c.symbol === 'USDT')!.id); expect(usdt.maxOrder[usdt.assets[0]!]).toBe('25000000000000000000');159    const eth = getNetwork('ethereum'), m = routes.mandateFor(agent({ network: 'ethereum', universe: ['BTC', 'ETH'] }), 'spw-0123456789abcdef', 'ab'.repeat(32), px, now);160    const wbtc = eth.tokens.find((t) => t.symbol === 'WBTC')!.id;161    expect(m.assets).toContain(wbtc); expect(m.maxOrder[wbtc]).toBe('41667'); // 25 USD of bitcoin, in WBTC's 8 decimals, at the BTC price162    // the owner chose tBTC (18 decimals) for the same symbol: the same 25 USD of bitcoin, in tBTC's own decimals, at the BTC price163    const tbtc = eth.tokens.find((t) => t.symbol === 'tBTC')!.id, m2 = routes.mandateFor(agent({ network: 'ethereum', universe: ['BTC', 'ETH'], assets: { BTC: tbtc } }), 'spw-0123456789abcdef', 'ab'.repeat(32), px, now);164    expect(m2.assets).toContain(tbtc); expect(m2.assets).not.toContain(wbtc); expect(m2.maxOrder[tbtc]).toBe('416670000000000'); // 0.00041667 tBTC165  });166  it('is refused when the signer could never price a symbol, when a price is missing, or above 12 assets', () => {167    expect(() => routes.mandateFor(agent({ universe: ['ETH', 'EUR'] }), 'spw-0123456789abcdef', 'ab'.repeat(32), px, now)).toThrow(/EUR is not listed on both/);168    expect(() => routes.mandateFor(agent({ universe: ['ETH', 'LINK'] }), 'spw-0123456789abcdef', 'ab'.repeat(32), { ETH: 2000 }, now)).toThrow(/no market price for LINK/);169    const all = tradableSymbols(getNetwork('ethereum')).filter((t) => t.live).map((t) => t.symbol);170    expect(all.length).toBeGreaterThanOrEqual(11);171    const prices = Object.fromEntries(all.map((s) => [s, 7]));172    expect(routes.mandateFor(agent({ network: 'ethereum', universe: all.slice(0, 11) }), 'spw-0123456789abcdef', 'ab'.repeat(32), prices, now).assets).toHaveLength(12); // 11 symbols + cash173    if (all.length >= 12) expect(() => routes.mandateFor(agent({ network: 'ethereum', universe: all.slice(0, 12) }), 'spw-0123456789abcdef', 'ab'.repeat(32), prices, now)).toThrow(/at most 12 assets/);174  });175});176177describe('strategy ↔ agent fit', () => {178  const info = (universe: string[], o: { min?: number; max?: number; interval?: string } = {}) => { const generic = strategies.isGeneric(universe);179    return { generic, minSymbols: generic ? o.min ?? 1 : universe.length, maxSymbols: generic ? o.max ?? 12 : universe.length, meta: { name: 's', version: '3', universe, interval: o.interval ?? '1h', lookback: 10, params: {} } }; };180  it('a generic strategy fits any universe within its bounds; a fixed one needs the same symbols', () => {181    expect(strategies.isGeneric(['*'])).toBe(true);182    for (const u of [['*', 'ETH'], ['ETH'], ['*', '*'], []]) expect(strategies.isGeneric(u)).toBe(false);183    for (const u of [['ETH'], ['ETH', 'LINK', 'UNI'], TWELVE]) expect(strategies.fitsAgent(info(['*']), u, '1h')).toEqual({ ok: true });184    expect(strategies.fitsAgent(info(['*'], { min: 3 }), ['ETH', 'LINK'], '1h')).toMatchObject({ ok: false, code: 'universe_size' }); // cross-sectional momentum185    expect(strategies.fitsAgent(info(['*'], { min: 3 }), ['ETH', 'LINK', 'UNI'], '1h')).toEqual({ ok: true });186    expect(strategies.fitsAgent(info(['*'], { max: 4 }), TWELVE, '1h')).toMatchObject({ ok: false, code: 'universe_size' });187    expect(strategies.fitsAgent(info(['*']), ['ETH'], '1d')).toMatchObject({ ok: false, code: 'interval_mismatch' });188    expect(strategies.fitsAgent(info(['ETH', 'LINK', 'AAVE']), ['AAVE', 'ETH', 'LINK'], '1h')).toEqual({ ok: true });189    expect(strategies.fitsAgent(info(['ETH', 'LINK', 'AAVE']), ['ETH', 'LINK'], '1h')).toMatchObject({ ok: false, code: 'universe_mismatch' });190    expect(strategies.fitsAgent(info(['ETH', 'LINK', 'AAVE']), ['ETH', 'LINK', 'UNI'], '1h')).toMatchObject({ ok: false, code: 'universe_mismatch' });191  });192  it('backtests on one candle source: the first that lists every symbol', () => {193    expect(strategies.backtestSource(['ETH', 'LINK', 'UNI'])).toBe('coinbase');194    expect(strategies.backtestSource(['ETH', 'EUR'])).toBe('bitstamp'); // the euro is not on Coinbase195    expect(strategies.backtestSource(['ETH', 'EUR', 'USDS'])).toBe('kraken'); // USDS is not on Bitstamp196    expect(strategies.backtestSource(['ETH', 'NOPE'])).toBeNull();197  });198});199200describe('market symbol set: derived from the registry, bounded, agents first', () => {201  const BASE = ['BTC', 'ETH', 'SOL', 'LINK', 'AAVE', 'AVAX', 'BNB', 'DOGE', 'LTC', 'XRP'];202  it('is the base ten plus every symbol tradable on an execution-enabled network, cut at the limit', () => {203    const reg = symbols.registrySymbols(), set = symbols.configuredSymbols([], {});204    expect(reg).toEqual(expect.arrayContaining(['ETH', 'BTC', 'LINK', 'UNI', 'AAVE', 'POL', 'BNB', 'AVAX', 'HYPE', 'EUR']));205    expect(reg).not.toContain('USDC'); expect(reg).not.toContain('USDT'); // cash legs are never market symbols206    for (const s of ['USDC.e', 'USDT0', 'BTC.b', 'WETH.e', 'LINK.e', 'WBTC', 'cbBTC', 'WETH', 'wstETH']) expect(reg, s).not.toContain(s); // registry tickers are not market symbols: wrappers count as what they track, bridged variants as nothing207    // exactly what the registry says, most widely available first: a symbol is there when an asset of an execution-enabled network tracks it208    const count = (s: string) => MANDATE_NETWORKS.filter((id) => tradableSymbols(getNetwork(id)).some((t) => t.symbol === s)).length;209    expect(new Set(reg)).toEqual(new Set(MANDATE_NETWORKS.flatMap((id) => tradableSymbols(getNetwork(id)).map((t) => t.symbol))));210    for (let k = 1; k < reg.length; k++) expect(count(reg[k - 1]!) > count(reg[k]!) || (count(reg[k - 1]!) === count(reg[k]!) && reg[k - 1]! < reg[k]!), `${reg[k - 1]} before ${reg[k]}`).toBe(true);211    expect(reg).toContain('SOL'); expect(count('SOL')).toBe(1); // since registry v5: Binance-Peg SOL on BNB Smart Chain212    // the set: the base ten first, then the registry-derived symbols in that order, cut at the limit (default 40); the rest is reported213    const ordered = [...new Set([...BASE, ...reg])];214    expect(set.symbols.slice(0, 10)).toEqual(BASE);215    expect(set.symbols).toEqual(ordered.slice(0, 40)); expect(set.dropped).toEqual(ordered.slice(40));216    expect(set.symbols.length).toBeLessThanOrEqual(40); expect(set.first).toEqual([]);217    expect(symbols.configuredSymbols([], { SPW_MARKET_MAX_SYMBOLS: '12' }).symbols).toEqual(ordered.slice(0, 12));218    expect(symbols.configuredSymbols([], { SPW_MARKET_MAX_SYMBOLS: '500' })).toMatchObject({ symbols: ordered, dropped: [] });219    expect(set.fiveMinute).toEqual(BASE); // 5-minute bars stay on the base set220    // a fixed registry: the order and the cut do not depend on today's tokens221    const inj = symbols.configuredSymbols([], { SPW_MARKET_MAX_SYMBOLS: '13' }, ['UNI', 'BTC', 'NOPE', 'POL', 'HYPE', 'PEPE']);222    expect(inj.symbols).toEqual([...BASE, 'UNI', 'POL', 'HYPE']); expect(inj.dropped).toEqual(['PEPE']); // BTC once, NOPE has no market data223  });224  it('bounds the backfill load however large the registry grows; only symbols of agents go past the limit', () => {225    // an injected registry of more than 45 tradable symbols: still 40 series per interval, 10 on 5-minute bars226    const every = ['BTC', 'ETH', 'SOL', 'LINK', 'AAVE', 'AVAX', 'BNB', 'DOGE', 'LTC', 'XRP', 'UNI', 'POL', 'HYPE', 'LDO', 'ONDO', 'PEPE', 'SHIB', 'PAXG', 'ADA', 'DOT', 'ATOM', 'NEAR', 'ARB', 'OP', 'CRV', 'MKR', 'COMP', 'SNX', 'GRT', 'FIL', 'ICP', 'APT', 'SUI', 'SEI', 'TIA', 'INJ', 'RENDER', 'FET', 'ALGO', 'XLM', 'ETC', 'BCH', 'TRX', 'TON', 'ENA', 'JUP', 'WIF', 'BONK', 'PENDLE', 'EUR', 'USDS', 'MORPHO', 'CAKE', 'AERO', 'EIGEN'].filter((s) => hasMarketData(s));227    expect(every.length).toBeGreaterThan(45);228    const none = symbols.configuredSymbols([], {}, every);229    expect(none.symbols).toHaveLength(40); expect(none.symbols.slice(0, 10)).toEqual(BASE); expect(none.dropped).toHaveLength(every.length - 40);230    const agents = every.slice(-3), withAgents = symbols.configuredSymbols(agents, {}, every); // three symbols from the very end of the list231    expect(withAgents.symbols.slice(0, 3)).toEqual(agents); expect(withAgents.symbols.slice(3, 13)).toEqual(BASE); expect(withAgents.symbols).toHaveLength(40);232    expect(withAgents.dropped.some((s) => agents.includes(s))).toBe(false);233    // only agents can push it past the limit: 12 symbols of one agent with a limit of 5 → those 12, nothing else234    const twelve = every.slice(20, 32), tight = symbols.configuredSymbols(twelve, { SPW_MARKET_MAX_SYMBOLS: '5' }, every);235    expect(tight.symbols).toEqual(twelve); expect(tight.fiveMinute).toEqual(BASE.filter((s) => twelve.includes(s)));236    const cfg = { symbols: none.symbols, intervals: ['1h', '1d', '5m'] as const, only: { '5m': none.fiveMinute }, first: none.first, depthMs: { '1h': 1, '1d': null, '5m': 1 } };237    expect(pipeline.seriesOrder(cfg as never)).toHaveLength(40 + 40 + 10);238  });239  it('puts the symbols of agents first, never drops them, and honours the limit and the explicit list', () => {240    const many = Array.from({ length: 60 }, (_, k) => ['UNI', 'POL', 'HYPE', 'LDO', 'ONDO', 'PEPE', 'SHIB', 'PAXG'][k % 8]!);241    const set = symbols.configuredSymbols(['UNI', 'NOPE', 'PEPE'], { SPW_MARKET_MAX_SYMBOLS: '5' });242    expect(set.first).toEqual(['UNI', 'PEPE']); // a symbol without market data is ignored243    expect(set.symbols).toEqual(['UNI', 'PEPE', 'BTC', 'ETH', 'SOL']); expect(set.dropped).toContain('LINK');244    expect(symbols.configuredSymbols(['SHIB', 'LDO', 'ONDO'], { SPW_MARKET_MAX_SYMBOLS: '2' }).symbols).toEqual(['SHIB', 'LDO', 'ONDO']);245    expect(symbols.configuredSymbols(['UNI'], { SPW_MARKET_SYMBOLS: 'BTC, ETH,NOPE' }).symbols).toEqual(['UNI', 'BTC', 'ETH']);246    expect(symbols.configuredSymbols([], { SPW_MARKET_SYMBOLS: 'BTC,UNI', SPW_MARKET_5M_SYMBOLS: 'UNI,DOGE' }).fiveMinute).toEqual(['UNI']);247    expect(symbols.configuredSymbols(many, {}).first).toHaveLength(8);248    // injected registry: forty more tradable symbols do not push the set past the limit249    const big = symbols.configuredSymbols(['PEPE'], {}, ['ADA', 'DOT', 'ATOM', 'NEAR', 'ARB', 'OP', 'CRV', 'MKR', 'COMP', 'SNX', 'GRT', 'FIL', 'ICP', 'APT', 'SUI', 'SEI', 'TIA', 'INJ', 'RENDER', 'FET', 'ALGO', 'XLM', 'ETC', 'BCH', 'TRX', 'TON', 'ENA', 'JUP', 'WIF', 'BONK', 'PENDLE', 'LDO', 'ONDO', 'UNI', 'POL']);250    expect(big.symbols).toHaveLength(40); expect(big.symbols[0]).toBe('PEPE'); expect(big.dropped.length).toBeGreaterThan(0);251  });252  it('serves every interval of the agents\' symbols before the rest (update and backfill order)', () => {253    const order = pipeline.seriesOrder({ symbols: ['UNI', 'BTC', 'ETH'], intervals: ['1h', '1d', '5m'], only: { '5m': ['BTC'] }, first: ['UNI'], depthMs: { '1h': 1, '1d': null, '5m': 1 } });254    expect(order).toEqual([['1h', 'UNI'], ['1d', 'UNI'], ['1h', 'BTC'], ['1h', 'ETH'], ['1d', 'BTC'], ['1d', 'ETH'], ['5m', 'BTC']]);255  });256});257
Files main
Menu

Theme