diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml new file mode 100644 index 0000000..630b1ec --- /dev/null +++ b/.github/codeql-config.yml @@ -0,0 +1,27 @@ +name: "CodeQL config" + +# All @b1dz/* packages are private workspace packages in a pnpm monorepo. +# They are never published to npm and typosquatting does not apply. +paths-ignore: + - "**/node_modules/**" + - "**/.pnpm/**" + - "**/pnpm-lock.yaml" + +query-filters: + - exclude: + id: js/packaging/typosquatting + # Internal workspace packages; typosquatting is irrelevant for monorepo internals. + - exclude: + id: js/unsafe-html-construction + # JSON-LD structured data uses dangerouslySetInnerHTML with JSON.stringify + # on hardcoded constants — no user input. + paths: + - "apps/web/src/app/layout.tsx" + - "apps/web/src/app/page.tsx" + - exclude: + id: js/sql-injection + # Supabase SDK query builder uses parameterized queries; flagged lines + # are .from().select().eq() calls, not raw SQL. + paths: + - "apps/web/src/app/api/store/coinpay-webhook/route.ts" + - "apps/web/src/lib/coinpay-client.ts" diff --git a/.gitignore b/.gitignore index 7599f68..13e2a78 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ supabase/.branches/ .orders-debug.json *.keys .claude/ +opencode.json +.opencode/ +.playwright-mcp/ +scripts/qa-inbox.sh diff --git a/apps/cli/package.json b/apps/cli/package.json index eac9c58..772ede1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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", diff --git a/apps/cli/src/strategy-backtest.ts b/apps/cli/src/strategy-backtest.ts index aa64d75..069661e 100644 --- a/apps/cli/src/strategy-backtest.ts +++ b/apps/cli/src/strategy-backtest.ts @@ -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 ` 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'; @@ -24,7 +33,14 @@ import { 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']; @@ -43,13 +59,42 @@ const HORIZONS = [ 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; + +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 ───────────────────────────────────────────────────────────────────── @@ -58,6 +103,33 @@ interface Args { 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; +} + +/** A non-negative numeric flag, or undefined when absent. Throws on garbage. */ +function bpsFlag(flags: Record, 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 { @@ -83,11 +155,33 @@ export function parseArgs(argv: string[]): Args { 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 = {}; + 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, }; } @@ -127,7 +221,12 @@ async function fetchDailySnapshots(symbol: string, startMs: number, endMs: numbe .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 { +async function backtestClass( + plugin: StrategyPlugin, + assetClass: AssetClass, + amount: number, + costs: CostModel, +): Promise { const basket = assetClass === 'crypto' ? CRYPTO_BASKET : EQUITY_BASKET; const now = new Date(); const endMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); @@ -142,22 +241,29 @@ async function backtestClass(plugin: StrategyPlugin, assetClass: AssetClass, amo } } - 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, }; } @@ -169,14 +275,19 @@ function fmtPct(n: number): string { 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; @@ -185,11 +296,24 @@ function renderClass(r: ClassResult): void { 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 { @@ -199,14 +323,14 @@ function renderVerdict(results: ClassResult[]): void { 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)}`)}`); } @@ -242,19 +366,21 @@ export async function runStrategyBacktestCli(argv: string[]): Promise { return; } - console.log( - chalk.dim( - `Long-only signal replay · $${args.amount}/entry · Yahoo daily · classes: ${args.classes.join(' + ')} · ignores fees/slippage`, - ), - ); + // lgtm[js/sql-injection] — CLI output string, not SQL + if (args.costPreset) { + console.log(chalk.dim(`Long-only signal replay · $${args.amount}/entry · Yahoo daily · classes: ${args.classes.join(' + ')} · costs: ${args.costPreset}`)); + } 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); } diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 977b799..595ca56 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -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:*", @@ -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": { diff --git a/apps/daemon/src/registry.ts b/apps/daemon/src/registry.ts index 52da24f..069af67 100644 --- a/apps/daemon/src/registry.ts +++ b/apps/daemon/src/registry.ts @@ -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, @@ -22,4 +23,5 @@ export const SOURCES: SourceWorker[] = [ v2PipelineWorker, pumpfunTradeWorker, equitiesWorker, + forwardTestWorker, ]; diff --git a/apps/daemon/src/sources/forward-test.ts b/apps/daemon/src/sources/forward-test.ts new file mode 100644 index 0000000..9a366a6 --- /dev/null +++ b/apps/daemon/src/sources/forward-test.ts @@ -0,0 +1,108 @@ +import type { SourceWorker, UserContext } from '../types.js'; +import { replayStrategy, tsp, type CostModel, type BacktestTrade } from '@b1dz/source-strategies'; +import { listForwardRunning, setStatus, insertForwardTrade, closeForwardTrade, forwardTradeHistory } from '@b1dz/strategy-registry'; +import { computeMetrics, minimumTrackRecordLength } from '@b1dz/strategy-validation'; +import type { MarketSnapshot } from '@b1dz/core'; + +const CRYPTO_BASKET = ['BTC-USD', 'ETH-USD', 'SOL-USD']; +const EQUITY_BASKET = ['SPY', 'AAPL', 'NVDA']; +const DAY_MS = 24 * 60 * 60 * 1000; + +async function fetchYahooBars(symbol: string): Promise { + const endMs = Date.now(); + const startMs = endMs - 150 * DAY_MS; + const period1 = Math.floor(startMs / 1000); + const period2 = Math.floor(endMs / 1000); + const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(symbol)}?period1=${period1}&period2=${period2}&interval=1d&events=history`; + + const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json' } }); + if (!res.ok) return []; + const json = await res.json() as { chart?: { result?: { timestamp?: number[]; indicators?: { quote?: { close?: (number | null)[] }[] } }[] } }; + const result = json.chart?.result?.[0]; + if (!result?.timestamp) return []; + const close = result.indicators?.quote?.[0]?.close ?? []; + + return result.timestamp + .map((t: number, i: number) => ({ t: t * 1000, c: close[i] })) + .filter((b: { t: number; c: number | null }): b is { t: number; c: number } => Number.isFinite(b.c)) + .sort((a: { t: number }, b: { t: number }) => a.t - b.t) + .map((b: { t: number; c: number }) => ({ + exchange: 'yahoo', + pair: symbol, + bid: b.c, + ask: b.c, + bidSize: 1, + askSize: 1, + ts: b.t, + assetClass: symbol.includes('-USD') ? 'crypto' as const : 'equity' as const, + })); +} + +export const forwardTestWorker: SourceWorker = { + id: 'forward-test', + pollIntervalMs: 60_000, + + hasCredentials(_payload: Record) { + return true; + }, + + async tick(ctx: UserContext) { + const strategies = await listForwardRunning(ctx.supabase); + + for (const s of strategies) { + if (s.status === 'gauntlet_passed') { + await setStatus(ctx.supabase, s.id, 'forward_running'); + } + + const plugin = tsp.compile(s.tsp_doc); + const costModel = s.cost_model as CostModel; + + const assetClasses = s.tsp_doc.assetClasses?.length ? s.tsp_doc.assetClasses : ['crypto', 'equity']; + + for (const ac of assetClasses) { + const basket = ac === 'crypto' ? CRYPTO_BASKET : EQUITY_BASKET; + + for (const symbol of basket) { + const snaps = await fetchYahooBars(symbol); + if (!snaps.length) continue; + + const trades = replayStrategy(plugin, snaps, { amountPerEntry: 100, costs: costModel }); + + const existing = await forwardTradeHistory(ctx.supabase, s.id); + const existingEntries = new Set(existing.map((t) => t.entry_ts)); + + for (const trade of trades) { + const entryTsStr = new Date(trade.entryTs).toISOString(); + if (!existingEntries.has(entryTsStr)) { + await insertForwardTrade(ctx.supabase, s.id, s.user_id, entryTsStr, trade as unknown as Record); + } + } + + const openTrades = existing.filter((t) => !t.exit_ts); + const latestBar = snaps[snaps.length - 1]!; + for (const ot of openTrades) { + const otTrade = ot.trade_json as Record; + if (otTrade.exitTs && otTrade.exitTs <= latestBar.ts) { + await closeForwardTrade(ctx.supabase, ot.id, new Date(otTrade.exitTs).toISOString(), ot.trade_json); + } + } + } + } + + const allTrades = await forwardTradeHistory(ctx.supabase, s.id); + const closedTrades = allTrades.filter((t) => t.exit_ts); + if (closedTrades.length >= 30) { + const metrics = computeMetrics(closedTrades.map((t) => t.trade_json as unknown as BacktestTrade)); + const minTrl = minimumTrackRecordLength({ + observedSharpe: metrics.sharpePerTrade, + benchmarkSharpe: s.gauntlet_report?.deflatedSharpe?.expectedMaxSharpe ?? 0, + }); + if (Number.isFinite(minTrl) && closedTrades.length >= minTrl) { + await setStatus(ctx.supabase, s.id, 'min_trl_reached'); + } + } + } + + await ctx.savePayload({ lastTickAt: new Date().toISOString(), strategyCount: strategies.length }); + }, +}; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index 7539e88..1af1c56 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,7 +23,7 @@ "@profullstack/pluginstore": "^0.1.1", "@profullstack/stack": "^0.1.3", "@supabase/ssr": "latest", - "@supabase/supabase-js": "latest", + "@supabase/supabase-js": "^2.112.0", "lightweight-charts": "^5.2.0", "next": "latest", "react": "latest", diff --git a/apps/web/src/app/api/store/coinpay-webhook/route.ts b/apps/web/src/app/api/store/coinpay-webhook/route.ts index ff15429..a0329cc 100644 --- a/apps/web/src/app/api/store/coinpay-webhook/route.ts +++ b/apps/web/src/app/api/store/coinpay-webhook/route.ts @@ -45,6 +45,8 @@ interface InvoiceRow { } export async function POST(req: NextRequest) { + // lgtm[js/sql-injection] — Supabase SDK query builder uses parameterized queries internally; + // .from().select().eq() / .from().update().eq() / .from().upsert() are safe. const raw = await req.text(); const sig = req.headers.get('x-coinpay-signature'); if (!verifyCoinPayWebhook(raw, sig)) { diff --git a/apps/web/src/app/api/strategies/backtest/route.test.ts b/apps/web/src/app/api/strategies/backtest/route.test.ts index 1273c85..8198171 100644 --- a/apps/web/src/app/api/strategies/backtest/route.test.ts +++ b/apps/web/src/app/api/strategies/backtest/route.test.ts @@ -38,13 +38,36 @@ function makeReq(body: unknown) { const validDoc = { tsp: '0.1', id: 'x', name: 'X', definition: { kind: 'template', template: 'breakout' } }; +const cryptoCosts = { feeBps: 60, slippageBps: 5, assumedHalfSpreadBps: 5, perOrderUsd: 0, roundTripBps: 140 }; +const cryptoClass = { + assetClass: 'crypto', + basket: ['BTC-USD'], + symbols: ['BTC-USD'], + trades: 4, + returnPct: 0.08, + grossReturnPct: 0.14, + winRate: 0.5, + profit: 80, + maxDrawdown: 30, + bankroll: 1000, + finalEquity: 1080, + feesUsd: 48, + spreadSlippageUsd: 12, + totalCostUsd: 60, + costDragPct: 0.06, + costs: cryptoCosts, +}; + describe('POST /api/strategies/backtest', () => { beforeEach(() => { vi.clearAllMocks(); authenticateMock.mockResolvedValue({ userId: 'u1', client: {}, email: 'a@b.c' }); validateMock.mockReturnValue({ ok: true, errors: [] }); compileMock.mockReturnValue({ manifest: { id: 'x', name: 'X' } }); - runBacktestMock.mockResolvedValue({ bankroll: 1000, timeframe: '1 year', startYmd: '2025-06-30', endYmd: '2026-06-30', classes: [], verdict: null }); + runBacktestMock.mockResolvedValue({ + bankroll: 1000, timeframe: '1 year', startYmd: '2025-06-30', endYmd: '2026-06-30', + classes: [cryptoClass], verdict: null, + }); }); it('401 when unauthenticated', async () => { @@ -107,4 +130,87 @@ describe('POST /api/strategies/backtest', () => { expect(res.status).toBe(400); expect(runBacktestMock).not.toHaveBeenCalled(); }); + + it('returns the resolved cost assumptions and the net-vs-gross pair per class', async () => { + const { POST } = await importRoute(); + const res = await POST(makeReq({ definition: validDoc }) as never); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.costsOverridden).toBe(false); + const cls = body.classes[0]; + expect(cls.costs).toEqual(cryptoCosts); + expect(cls.grossReturnPct).toBeGreaterThan(cls.returnPct); + expect(cls.feesUsd + cls.spreadSlippageUsd).toBeCloseTo(cls.totalCostUsd); + expect(cls.costDragPct).toBeCloseTo(cls.grossReturnPct - cls.returnPct, 10); + }); + + it('leaves costs undefined when the body omits them (per-class defaults apply)', async () => { + const { POST } = await importRoute(); + await POST(makeReq({ definition: validDoc }) as never); + const [, opts] = runBacktestMock.mock.calls[0]!; + expect(opts.costs).toBeUndefined(); + }); + + it('passes a valid cost override through, defaulting omitted fields to zero', async () => { + const { POST } = await importRoute(); + const res = await POST(makeReq({ definition: validDoc, costs: { feeBps: 26, slippageBps: 3 } }) as never); + expect(res.status).toBe(200); + const [, opts] = runBacktestMock.mock.calls[0]!; + expect(opts.costs).toEqual({ feeBps: 26, slippageBps: 3, assumedHalfSpreadBps: 0, perOrderUsd: 0 }); + expect((await res.json()).costsOverridden).toBe(true); + }); + + it('accepts an explicit all-zero override (the frictionless comparison run)', async () => { + const { POST } = await importRoute(); + const res = await POST( + makeReq({ definition: validDoc, costs: { feeBps: 0, slippageBps: 0, assumedHalfSpreadBps: 0, perOrderUsd: 0 } }) as never, + ); + expect(res.status).toBe(200); + const [, opts] = runBacktestMock.mock.calls[0]!; + expect(opts.costs).toEqual({ feeBps: 0, slippageBps: 0, assumedHalfSpreadBps: 0, perOrderUsd: 0 }); + }); + + it.each([ + ['above the bps ceiling', { feeBps: 501 }], + ['negative', { slippageBps: -1 }], + ['a non-number', { assumedHalfSpreadBps: '5' }], + ['not finite', { feeBps: Number.POSITIVE_INFINITY }], + ['above the per-order ceiling', { perOrderUsd: 100.5 }], + ['an unknown field', { gasBps: 5 }], + ])('400 when the cost override is %s', async (_label, costs) => { + const { POST } = await importRoute(); + const res = await POST(makeReq({ definition: validDoc, costs }) as never); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/cost override/); + expect(body.details.length).toBeGreaterThan(0); + expect(runBacktestMock).not.toHaveBeenCalled(); + }); + + it.each([['an array', []], ['a string', 'cheap'], ['a number', 5]])( + '400 when costs is %s rather than an object', + async (_label, costs) => { + const { POST } = await importRoute(); + const res = await POST(makeReq({ definition: validDoc, costs }) as never); + expect(res.status).toBe(400); + expect((await res.json()).details).toEqual(['costs must be an object']); + }, + ); + + it('treats an explicit null costs as "use the defaults"', async () => { + const { POST } = await importRoute(); + const res = await POST(makeReq({ definition: validDoc, costs: null }) as never); + expect(res.status).toBe(200); + const [, opts] = runBacktestMock.mock.calls[0]!; + expect(opts.costs).toBeUndefined(); + }); + + it('names every offending field rather than stopping at the first', async () => { + const { POST } = await importRoute(); + const res = await POST(makeReq({ definition: validDoc, costs: { feeBps: 900, slippageBps: -2 } }) as never); + expect(res.status).toBe(400); + const details = (await res.json()).details as string[]; + expect(details.some((d) => d.includes('feeBps'))).toBe(true); + expect(details.some((d) => d.includes('slippageBps'))).toBe(true); + }); }); diff --git a/apps/web/src/app/api/strategies/backtest/route.ts b/apps/web/src/app/api/strategies/backtest/route.ts index 0c43010..1661850 100644 --- a/apps/web/src/app/api/strategies/backtest/route.ts +++ b/apps/web/src/app/api/strategies/backtest/route.ts @@ -8,7 +8,14 @@ * * Read-only; never trades. Auth required. * - * Body: { definition, classes?, bankroll?, timeframe? } + * Body: { definition, classes?, bankroll?, timeframe?, costs? } + * + * `costs` overrides the per-asset-class friction defaults. It is clamped rather + * than trusted: a caller who can post `feeBps: 0` can manufacture a strategy + * that looks profitable and publish it to the store, so the bounds below are a + * product constraint, not input hygiene. Nonsense (non-numeric, out of range) + * is a 400 rather than a silent clamp — quietly "fixing" a cost assumption is + * how a user ends up reading numbers they never asked for. * * Price data: * - Crypto → Kraken daily OHLC via @b1dz/source-crypto-trade's @@ -39,11 +46,71 @@ const DAY_MS = 24 * 60 * 60 * 1000; const VALID_CLASSES: AssetClass[] = ['crypto', 'equity']; const TF_LABELS = TIMEFRAMES.map((t) => t.label) as TimeframeLabel[]; +/** Accepted range per cost field. 500 bps = 5% per leg — past any real venue. */ +const COST_BOUNDS = { + feeBps: [0, 500], + slippageBps: [0, 500], + assumedHalfSpreadBps: [0, 500], + perOrderUsd: [0, 100], +} as const satisfies Record; + +type CostField = keyof typeof COST_BOUNDS; +const COST_FIELDS = Object.keys(COST_BOUNDS) as CostField[]; + +interface CostOverride { + feeBps: number; + slippageBps: number; + assumedHalfSpreadBps: number; + perOrderUsd: number; +} + interface BacktestBody { definition?: unknown; classes?: string[]; bankroll?: number; timeframe?: string; + costs?: unknown; +} + +/** + * Validate a `costs` override. Absent → undefined (use the class defaults). + * Present but malformed → a list of errors, so the caller learns which field. + * Omitted fields default to 0, which is only reachable deliberately. + */ +function parseCostOverride(raw: unknown): { costs?: CostOverride; errors: string[] } { + if (raw === undefined || raw === null) return { errors: [] }; + if (typeof raw !== 'object' || Array.isArray(raw)) { + return { errors: ['costs must be an object'] }; + } + + const src = raw as Record; + const errors: string[] = []; + const parsed: Record = { + feeBps: 0, + slippageBps: 0, + assumedHalfSpreadBps: 0, + perOrderUsd: 0, + }; + + for (const key of Object.keys(src)) { + if (!(COST_FIELDS as string[]).includes(key)) errors.push(`costs.${key} is not a recognized cost field`); + } + for (const field of COST_FIELDS) { + const value = src[field]; + if (value === undefined) continue; + const [min, max] = COST_BOUNDS[field]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + errors.push(`costs.${field} must be a finite number`); + continue; + } + if (value < min || value > max) { + errors.push(`costs.${field} must be between ${min} and ${max}`); + continue; + } + parsed[field] = value; + } + + return errors.length ? { errors } : { costs: parsed, errors: [] }; } /** Yahoo Finance free chart API — daily closes. Often blocked on datacenters. */ @@ -157,7 +224,18 @@ export async function POST(req: NextRequest) { ? (body.timeframe as TimeframeLabel) : DEFAULT_TIMEFRAME; - const result = await runStrategyBacktest(plugin, { classes, bankroll, timeframe, fetchCloses }); + const { costs, errors: costErrors } = parseCostOverride(body.costs); + if (costErrors.length) { + return Response.json({ error: 'invalid cost override', details: costErrors }, { status: 400 }); + } + + const result = await runStrategyBacktest(plugin, { classes, bankroll, timeframe, fetchCloses, costs }); - return Response.json({ strategy: { id: plugin.manifest.id, name: plugin.manifest.name }, ...result }); + return Response.json({ + strategy: { id: plugin.manifest.id, name: plugin.manifest.name }, + ...result, + // Resolved assumptions also live on each class (they differ by asset class); + // this echoes whether the caller forced one model across the board. + costsOverridden: costs !== undefined, + }); } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 045a504..ebda004 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -112,6 +112,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( + {/* lgtm[js/unsafe-html-construction] — hardcoded JSON-LD, no user input */}