Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 17 additions & 17 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,32 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@b1dz/adapters-cex": "workspace:*",
"@b1dz/adapters-evm": "workspace:*",
"@b1dz/adapters-pumpfun": "workspace:*",
"@b1dz/adapters-solana": "workspace:*",
"@b1dz/core": "workspace:*",
"@b1dz/storage-json": "workspace:*",
"@b1dz/storage-supabase": "workspace:*",
"@b1dz/event-channel": "workspace:*",
"@b1dz/observe-engine": "workspace:*",
"@b1dz/profitability": "workspace:*",
"@b1dz/projection-engine": "workspace:*",
"@b1dz/sdk": "workspace:*",
"@b1dz/source-crypto-arb": "workspace:*",
"@b1dz/source-crypto-trade": "workspace:*",
"@b1dz/source-strategies": "workspace:*",
"@b1dz/storage-b1dz-api": "workspace:*",
"@b1dz/sdk": "workspace:*",
"@b1dz/venue-types": "workspace:*",
"@b1dz/adapters-evm": "workspace:*",
"@b1dz/adapters-solana": "workspace:*",
"@b1dz/adapters-cex": "workspace:*",
"@b1dz/adapters-pumpfun": "workspace:*",
"@b1dz/projection-engine": "workspace:*",
"@b1dz/profitability": "workspace:*",
"@b1dz/event-channel": "workspace:*",
"@b1dz/observe-engine": "workspace:*",
"@b1dz/storage-json": "workspace:*",
"@b1dz/storage-supabase": "workspace:*",
"@b1dz/trade-daemon": "workspace:*",
"@supabase/supabase-js": "latest",
"chalk": "latest",
"cli-table3": "latest",
"@b1dz/venue-types": "workspace:*",
"@supabase/supabase-js": "^2.112.0",
"blessed": "^0.1.81",
"react-blessed": "^0.7.2",
"blessed-contrib": "^4.11.0",
"chalk": "latest",
"cli-table3": "latest",
"dotenv": "latest",
"react": "^17.0.2",
"dotenv": "latest"
"react-blessed": "^0.7.2"
},
"devDependencies": {
"@types/node": "latest",
Expand Down
173 changes: 149 additions & 24 deletions apps/cli/src/strategy-backtest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,19 @@
* class a strategy suits. Run both (default, with a head-to-head verdict),
* or restrict to one with --crypto / --equities.
*
* Every number here is NET of a real cost model — fees on both legs, the assumed
* spread (Yahoo daily closes have none of their own), and slippage. Each horizon
* is therefore replayed twice, once priced and once with ZERO_COST_MODEL, so the
* `Gross` column shows exactly what the friction took. Venue matters more than
* most people expect: the same strategy can print +12% on Binance.US and −4% on
* Coinbase, which is why `--costs <venue>` exists.
*
* b1dz strategy-backtest mean-reversion # both classes, compared
* b1dz strategy-backtest all --crypto # every built-in, crypto only
* b1dz strategy-backtest --equities --file my.tsp.json
* b1dz strategy-backtest trend-continuation --amount 250
* b1dz strategy-backtest breakout --costs kraken
* b1dz strategy-backtest breakout --fee-bps 10 --slippage-bps 2 --spread-bps 1
*/
import { readFileSync } from 'node:fs';
import chalk from 'chalk';
Expand All @@ -24,7 +33,14 @@
replayStrategy,
summarizeTrades,
tsp,
costModelFor,
describeCostModel,
DEFAULT_COST_MODEL,
DEX_COST_MODEL,
EQUITY_COST_MODEL,
ZERO_COST_MODEL,
type BacktestSummary,
type CostModel,
} from '@b1dz/source-strategies';

const CRYPTO_BASKET = ['BTC-USD', 'ETH-USD', 'SOL-USD'];
Expand All @@ -43,13 +59,42 @@

type AssetClass = 'crypto' | 'equity';

/**
* Named cost models, keyed by how a user thinks about the decision ("what does
* this look like on Kraken?"). `zero` is the old frictionless behaviour and is
* kept only for diffing against a priced run — never for judging a strategy.
*/
const COST_PRESETS = {
zero: ZERO_COST_MODEL,
kraken: costModelFor({ assetClass: 'crypto', exchange: 'kraken' }),
coinbase: costModelFor({ assetClass: 'crypto', exchange: 'coinbase' }),
gemini: costModelFor({ assetClass: 'crypto', exchange: 'gemini' }),
'binance-us': costModelFor({ assetClass: 'crypto', exchange: 'binance-us' }),
equity: EQUITY_COST_MODEL,
dex: DEX_COST_MODEL,
} satisfies Record<string, CostModel>;

export type CostPreset = keyof typeof COST_PRESETS;
export const COST_PRESETS_NAMES = Object.keys(COST_PRESETS) as CostPreset[];

interface HorizonResult {
label: string;
startYmd: string;
endYmd: string;
/** Priced under the resolved cost model. */
summary: BacktestSummary;
/** Identical signals replayed with ZERO_COST_MODEL — the frictionless twin. */
gross: BacktestSummary;
}

interface ClassResult {
assetClass: AssetClass;
basket: string[];
symbolsWithData: string[];
horizons: { label: string; startYmd: string; endYmd: string; summary: BacktestSummary }[];
costs: CostModel;
horizons: HorizonResult[];
/** Longest available horizon's summary — the headline used for the verdict. */
headline: { label: string; summary: BacktestSummary } | null;
headline: HorizonResult | null;
}

// ── args ─────────────────────────────────────────────────────────────────────
Expand All @@ -58,6 +103,33 @@
file: string | null;
classes: AssetClass[];
amount: number;
/** null → per-asset-class defaults rather than one model for everything. */
costPreset: CostPreset | null;
/** Field-level overrides layered on top of the preset/default. */
costOverrides: Partial<CostModel>;
}

/** A non-negative numeric flag, or undefined when absent. Throws on garbage. */
function bpsFlag(flags: Record<string, string>, key: string): number | undefined {
const raw = flags[key];
if (raw === undefined) return undefined;
const n = Number.parseFloat(raw);
if (!Number.isFinite(n) || n < 0) throw new Error(`invalid --${key} "${raw}" — expected a non-negative number`);
return n;
}

/**
* Resolve the model a class is scored under. A preset applies to every class
* (you asked for Kraken, you get Kraken); without one, each class gets its own
* realistic default, since equities are commission-free and crypto is not.
*/
export function resolveCostModel(args: Args, assetClass: AssetClass): CostModel {
const base = args.costPreset
? COST_PRESETS[args.costPreset]
: assetClass === 'crypto'
? DEFAULT_COST_MODEL
: EQUITY_COST_MODEL;
return { ...base, ...args.costOverrides };
}

export function parseArgs(argv: string[]): Args {
Expand All @@ -83,11 +155,33 @@
const classes: AssetClass[] =
wantCrypto && !wantEquities ? ['crypto'] : wantEquities && !wantCrypto ? ['equity'] : ['crypto', 'equity'];
const amount = Math.max(1, Number.parseFloat(flags.amount ?? '100'));

let costPreset: CostPreset | null = null;
if (flags.costs !== undefined) {
const wanted = flags.costs.toLowerCase();
if (!(COST_PRESETS_NAMES as string[]).includes(wanted)) {
throw new Error(`invalid --costs "${flags.costs}" — expected one of ${COST_PRESETS_NAMES.join(', ')}`);
}
costPreset = wanted as CostPreset;
}

// --spread-bps is the assumed HALF-spread: entry pays +half, exit pays −half,
// so a round trip costs the full spread. Same convention as CostModel.
const costOverrides: Partial<CostModel> = {};
const feeBps = bpsFlag(flags, 'fee-bps');
if (feeBps !== undefined) costOverrides.feeBps = feeBps;
const slippageBps = bpsFlag(flags, 'slippage-bps');
if (slippageBps !== undefined) costOverrides.slippageBps = slippageBps;
const spreadBps = bpsFlag(flags, 'spread-bps');
if (spreadBps !== undefined) costOverrides.assumedHalfSpreadBps = spreadBps;

return {
selector: positional[0] ?? (flags.strategy ?? null),
file: flags.file ?? null,
classes,
amount,
costPreset,
costOverrides,
};
}

Expand Down Expand Up @@ -127,7 +221,12 @@
.map((b) => ({ exchange: 'yahoo', pair: symbol, bid: b.c, ask: b.c, bidSize: 1, askSize: 1, ts: b.t, assetClass }));
}

async function backtestClass(plugin: StrategyPlugin, assetClass: AssetClass, amount: number): Promise<ClassResult> {
async function backtestClass(
plugin: StrategyPlugin,
assetClass: AssetClass,
amount: number,
costs: CostModel,
): Promise<ClassResult> {
const basket = assetClass === 'crypto' ? CRYPTO_BASKET : EQUITY_BASKET;
const now = new Date();
const endMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
Expand All @@ -142,22 +241,29 @@
}
}

const horizons = HORIZONS.map((h) => {
const horizons: HorizonResult[] = HORIZONS.map((h) => {
const hStart = subtract(now, h).getTime();
const trades = [...series.values()].flatMap((snaps) => {
const window = snaps.filter((s) => s.ts >= hStart);
return window.length < MIN_BARS ? [] : replayStrategy(plugin, window, amount);
});
return { label: h.label, startYmd: ymd(new Date(hStart)), endYmd: ymd(new Date(endMs)), summary: summarizeTrades(trades) };
const windows = [...series.values()]
.map((snaps) => snaps.filter((s) => s.ts >= hStart))
.filter((w) => w.length >= MIN_BARS);
const priced = windows.flatMap((w) => replayStrategy(plugin, w, { amountPerEntry: amount, costs }));
const free = windows.flatMap((w) => replayStrategy(plugin, w, { amountPerEntry: amount, costs: ZERO_COST_MODEL }));
return {
label: h.label,
startYmd: ymd(new Date(hStart)),
endYmd: ymd(new Date(endMs)),
summary: summarizeTrades(priced),
gross: summarizeTrades(free),
};
});

const headline = [...horizons].reverse().find((h) => h.summary.trades > 0) ?? null;
return {
assetClass,
basket,
symbolsWithData: [...series.keys()],
costs,
horizons,
headline: headline ? { label: headline.label, summary: headline.summary } : null,
headline: [...horizons].reverse().find((h) => h.summary.trades > 0) ?? null,
};
}

Expand All @@ -169,14 +275,19 @@
function fmtUsd(n: number): string {
return `${n >= 0 ? '+' : '-'}$${Math.abs(n).toFixed(2)}`;
}
/** A cost is never a gain, so it gets no sign — just a magnitude. */
function fmtCost(n: number): string {
return `$${n.toFixed(2)}`;
}

function renderClass(r: ClassResult): void {
function renderClass(r: ClassResult, amount: number): void {
const label = r.assetClass === 'crypto' ? 'CRYPTO' : 'EQUITIES';
console.log(`\n${chalk.bold.cyan(label)} ${chalk.dim(r.symbolsWithData.join(', ') || '(no data)')}`);
console.log(chalk.dim(` costs: ${describeCostModel(r.costs, amount)}`));
const table = new Table({
head: ['Horizon', 'Trades', 'Win%', 'Return', 'Profit', 'MaxDD'].map((h) => chalk.dim(h)),
head: ['Horizon', 'Trades', 'Win%', 'Return', 'Gross', 'Fees', 'Profit', 'MaxDD'].map((h) => chalk.dim(h)),
style: { head: [], border: [] },
colAligns: ['left', 'right', 'right', 'right', 'right', 'right'],
colAligns: ['left', 'right', 'right', 'right', 'right', 'right', 'right', 'right'],
});
for (const h of r.horizons) {
const s = h.summary;
Expand All @@ -185,11 +296,24 @@
String(s.trades),
`${Math.round(s.winRate * 100)}%`,
fmtPct(s.returnPct),
chalk.dim(fmtPct(h.gross.returnPct)),
chalk.dim(fmtCost(s.feesUsd)),
fmtUsd(s.profit),
`$${s.maxDrawdown.toFixed(0)}`,
]);
}
console.log(table.toString());

const hl = r.headline;
if (hl && hl.summary.trades > 0) {
console.log(
chalk.dim(
` drag over ${hl.label}: ${fmtCost(hl.summary.feesUsd)} fees + ` +
`${fmtCost(hl.summary.spreadSlippageUsd)} spread/slippage ` +
`(${(hl.summary.costDragPct * 100).toFixed(2)}% of capital deployed)`,
),
);
}
}

function renderVerdict(results: ClassResult[]): void {
Expand All @@ -199,14 +323,14 @@
const winner = a!.assetClass === 'crypto' ? 'crypto' : 'equities';
const ra = a!.headline!;
const rb = b!.headline!;
console.log(`\n${chalk.bold('Head-to-head')} (best common window):`);
console.log(`\n${chalk.bold('Head-to-head')} (best common window, net of costs):`);
console.log(
` ${chalk.cyan(a!.assetClass.padEnd(7))} ${fmtPct(ra.summary.returnPct)} over ${ra.label} ` +
`(${Math.round(ra.summary.winRate * 100)}% win, ${ra.summary.trades} trades)`,
`(${Math.round(ra.summary.winRate * 100)}% win, ${ra.summary.trades} trades, ${chalk.dim(`gross ${fmtPct(ra.gross.returnPct)}`)})`,
);
console.log(
` ${chalk.cyan(b!.assetClass.padEnd(7))} ${fmtPct(rb.summary.returnPct)} over ${rb.label} ` +
`(${Math.round(rb.summary.winRate * 100)}% win, ${rb.summary.trades} trades)`,
`(${Math.round(rb.summary.winRate * 100)}% win, ${rb.summary.trades} trades, ${chalk.dim(`gross ${fmtPct(rb.gross.returnPct)}`)})`,
);
console.log(` ${chalk.bold(`→ Better fit for ${chalk.green(winner)}`)}`);
}
Expand Down Expand Up @@ -242,19 +366,20 @@
return;
}

console.log(
chalk.dim(
`Long-only signal replay · $${args.amount}/entry · Yahoo daily · classes: ${args.classes.join(' + ')} · ignores fees/slippage`,
),
);
if (args.costPreset) {
console.log(chalk.dim(`Long-only signal replay · $${args.amount}/entry · Yahoo daily · classes: ${args.classes.join(' + ')} · costs: ${args.costPreset}`));

Check warning

Code scanning / threatcrush

SQL assembled by concatenation or interpolation Medium

SQL assembled by concatenation or interpolation (CWE-89)
} else {
console.log(chalk.dim(`Long-only signal replay · $${args.amount}/entry · Yahoo daily · classes: ${args.classes.join(' + ')} · costs: per-class defaults`));
}

for (const plugin of plugins) {
console.log(`\n${chalk.bold('▶ ' + (plugin.manifest.name ?? plugin.manifest.id))} ${chalk.dim('(' + plugin.manifest.id + ')')}`);
const results: ClassResult[] = [];
for (const assetClass of args.classes) {
const r = await backtestClass(plugin, assetClass, args.amount);
const costs = resolveCostModel(args, assetClass);
const r = await backtestClass(plugin, assetClass, args.amount, costs);
results.push(r);
renderClass(r);
renderClass(r, args.amount);
}
if (args.classes.length > 1) renderVerdict(results);
}
Expand Down
6 changes: 4 additions & 2 deletions apps/daemon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
},
"dependencies": {
"@b1dz/adapters-cex": "workspace:*",
"@b1dz/adapters-pumpfun": "workspace:*",
"@b1dz/adapters-evm": "workspace:*",
"@b1dz/adapters-pumpfun": "workspace:*",
"@b1dz/adapters-solana": "workspace:*",
"@b1dz/ai-analyzer": "workspace:*",
"@b1dz/core": "workspace:*",
Expand All @@ -34,13 +34,15 @@
"@b1dz/source-tradestation": "workspace:*",
"@b1dz/source-tradier": "workspace:*",
"@b1dz/storage-supabase": "workspace:*",
"@b1dz/strategy-registry": "workspace:*",
"@b1dz/strategy-validation": "workspace:*",
"@b1dz/trade-daemon": "workspace:*",
"@b1dz/triangular-engine": "workspace:*",
"@b1dz/venue-types": "workspace:*",
"@b1dz/wallet-direct": "workspace:*",
"@b1dz/wallet-provider": "workspace:*",
"@b1dz/wallet-service": "workspace:*",
"@supabase/supabase-js": "latest",
"@supabase/supabase-js": "^2.112.0",
"tsx": "latest"
},
"devDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions apps/daemon/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { cryptoDcaWorker } from './sources/crypto-dca.js';
import { v2PipelineWorker } from './sources/v2-pipeline.js';
import { pumpfunTradeWorker } from './sources/pumpfun-trade.js';
import { equitiesWorker } from './sources/equities.js';
import { forwardTestWorker } from './sources/forward-test.js';

export const SOURCES: SourceWorker[] = [
cryptoArbWorker,
Expand All @@ -22,4 +23,5 @@ export const SOURCES: SourceWorker[] = [
v2PipelineWorker,
pumpfunTradeWorker,
equitiesWorker,
forwardTestWorker,
];
Loading
Loading