diff --git a/apps/daemon/package.json b/apps/daemon/package.json index 433a6da..977b799 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -19,6 +19,7 @@ "@b1dz/adapters-pumpfun": "workspace:*", "@b1dz/adapters-evm": "workspace:*", "@b1dz/adapters-solana": "workspace:*", + "@b1dz/ai-analyzer": "workspace:*", "@b1dz/core": "workspace:*", "@b1dz/equity-engine": "workspace:*", "@b1dz/event-channel": "workspace:*", diff --git a/apps/daemon/src/sources/crypto-trade.ts b/apps/daemon/src/sources/crypto-trade.ts index 0031ac5..b134db4 100644 --- a/apps/daemon/src/sources/crypto-trade.ts +++ b/apps/daemon/src/sources/crypto-trade.ts @@ -9,7 +9,18 @@ import { setTradingOverride, setDailyLossLimitPct, setDexExecutor, + setSpendBudget, + setMaxPositionUsd, + syncSpendLedger, + drainSpendLedger, + isBudgetWindow, + windowStartFor, + setAiSizeMultiplier, + executeAgentMarketBuy, + type BudgetWindow, } from '@b1dz/source-crypto-trade'; +import { analyze, aiSizeMultiplier, RateLimiter, type AiAnalysis } from '@b1dz/ai-analyzer'; +import { loadUserConfig } from '../user-config.js'; import { AlertBus, getAnalysisCache, getB1dzVersion, setAnalysisCache } from '@b1dz/core'; import { runnerStorageFor } from '../runner-storage.js'; import { logActivity, logRaw, getActivityLog, getRawLog } from './activity-log.js'; @@ -34,6 +45,210 @@ let dexExecutorArmedAt: number | null = null; let dexExecutorLastAttemptLogAt = 0; const DEX_EXECUTOR_RETRY_LOG_INTERVAL_MS = 5 * 60_000; +/** userId → windowStart the spend ledger was last seeded for (re-seed on rollover). */ +const spendLedgerSeededFor = new Map(); + +/** + * Keep the engine's in-memory spend budget in sync with the durable + * `crypto_spend_ledger` table: + * 1. Once per (user, window) — sum this window's spend from the DB and seed + * the engine, so the budget survives daemon restarts. + * 2. Every tick — drain buys the engine just recorded and persist them. + * Best-effort: ledger failures never block trading (the in-memory counter is + * still authoritative for the live process). + */ +async function reconcileSpendLedger( + ctx: UserContext, + _storage: unknown, + window: BudgetWindow, +): Promise { + const windowStart = windowStartFor(window, Date.now()); + try { + if (spendLedgerSeededFor.get(ctx.userId) !== windowStart) { + const { data, error } = await ctx.supabase + .from('crypto_spend_ledger') + .select('usd') + .eq('user_id', ctx.userId) + .gte('ts', new Date(windowStart).toISOString()); + if (!error) { + const sum = (data ?? []).reduce((acc: number, r: { usd: number | string }) => acc + Number(r.usd ?? 0), 0); + syncSpendLedger(sum); + spendLedgerSeededFor.set(ctx.userId, windowStart); + } + } + } catch (e) { + logRaw(`[trade] spend-ledger seed failed: ${(e as Error).message}`, 'crypto-trade'); + } + + const entries = drainSpendLedger(); + if (entries.length === 0) return; + try { + await ctx.supabase.from('crypto_spend_ledger').insert( + entries.map((e) => ({ + user_id: ctx.userId, + ts: new Date(e.ts).toISOString(), + usd: e.usd, + exchange: e.exchange, + pair: e.pair, + source: e.source, + agent_token_id: e.tokenId ?? null, + })), + ); + } catch (e) { + logRaw(`[trade] spend-ledger write failed: ${(e as Error).message}`, 'crypto-trade'); + } +} + +/** Per-user AI-analyzer rate limiters + last-run timestamps. */ +const aiRateLimiters = new Map(); +const aiLastRunAt = new Map(); +const AI_MIN_INTERVAL_MS = 30_000; // never analyze more often than this, regardless of maxPerMin + +/** + * AI analyzer step (Phase 2 — "Coinbase Advisor" analog). When the user has + * enabled it AND set their own provider key, call OUT to their model to score + * the active market and set the engine's AI size multiplier. The multiplier + * only scales a buy the deterministic strategy already wants and is itself + * clamped + bounded by the spend budget. Best-effort: any failure resets the + * multiplier to neutral (1×) so a flaky key never blocks or distorts trading. + */ +async function reconcileAiAnalyzer(ctx: UserContext): Promise { + let analysis: AiAnalysis | null = null; + try { + const cfg = await loadUserConfig(ctx.userId); + const enabled = (cfg.getUserPlain('AI_ANALYZER_ENABLED') ?? '').toLowerCase() === 'true'; + if (!enabled) { + setAiSizeMultiplier(1); + return; + } + const provider = cfg.getUserPlain('AI_PROVIDER') === 'openai' ? 'openai' : 'anthropic'; + // STRICT per-user key — never operator env (env-fallback incident). + const apiKey = provider === 'openai' ? cfg.getUserSecret('OPENAI_API_KEY') : cfg.getUserSecret('ANTHROPIC_API_KEY'); + if (!apiKey) { + setAiSizeMultiplier(1); + return; + } + + const now = Date.now(); + if (now - (aiLastRunAt.get(ctx.userId) ?? 0) < AI_MIN_INTERVAL_MS) return; // keep last multiplier + const maxPerMin = Number(cfg.getUserPlain('AI_MAX_CALLS_PER_MIN') ?? '6'); + let rl = aiRateLimiters.get(ctx.userId); + if (!rl) { rl = new RateLimiter(Number.isFinite(maxPerMin) && maxPerMin > 0 ? maxPerMin : 6); aiRateLimiters.set(ctx.userId, rl); } + if (!rl.allow(now)) return; + aiLastRunAt.set(ctx.userId, now); + + // Use the active position with the most price history as the market read. + const status = getTradeStatus(); + const positions = status.positions ?? []; + const target = positions + .filter((p) => (p.priceSamples?.length ?? 0) >= 3) + .sort((a, b) => (b.priceSamples?.length ?? 0) - (a.priceSamples?.length ?? 0))[0]; + if (!target) { + setAiSizeMultiplier(1); // flat / not enough data → neutral + return; + } + + analysis = await analyze( + { + pair: target.pair, + exchange: target.exchange, + lastPrice: target.currentPrice, + closes: target.priceSamples.slice(-20), + deterministicSignal: null, + }, + { provider, apiKey, model: cfg.getUserPlain('AI_MODEL') || undefined }, + ); + setAiSizeMultiplier(aiSizeMultiplier(analysis, Date.now())); + } catch (e) { + setAiSizeMultiplier(1); // fail safe to neutral + logRaw(`[trade] AI analyzer skipped: ${(e as Error).message}`, 'crypto-trade'); + return; + } + + // Persist the latest analysis for the dashboard/TUI (best-effort). + if (analysis) { + try { + await ctx.supabase.from('source_state').upsert( + { user_id: ctx.userId, source_id: 'ai-analysis', payload: analysis as unknown as Record, updated_at: new Date().toISOString() }, + { onConflict: 'user_id,source_id' }, + ); + } catch { /* non-fatal */ } + } +} + +interface AgentQueueItem { + idempotencyKey: string; + tokenId: string; + pair: string; + exchange: string | null; + usd: number; + side: 'buy'; + enqueuedAt: string; +} + +/** + * Drain the agent-orders queue (Phase 3 — "Coinbase for Agents"). Orders are + * authorized + per-token-budgeted by the web API, then enqueued in source_state + * 'agent-orders'; here we execute each BUY through the engine, which re-applies + * the global spend budget + records the spend tagged source='agent' with the + * token id (so per-token budgets reconcile). Processed items are removed and + * the outcome written to agent_actions. + */ +async function reconcileAgentOrders(ctx: UserContext): Promise { + let queue: AgentQueueItem[]; + try { + const { data } = await ctx.supabase + .from('source_state') + .select('payload') + .eq('user_id', ctx.userId) + .eq('source_id', 'agent-orders') + .maybeSingle(); + queue = ((data?.payload as { queue?: AgentQueueItem[] } | undefined)?.queue ?? []) as AgentQueueItem[]; + } catch { + return; + } + if (queue.length === 0) return; + + for (const item of queue) { + let ok = false; + let message = 'skipped'; + try { + const res = await executeAgentMarketBuy({ pair: item.pair, exchange: item.exchange ?? undefined, usd: item.usd, tokenId: item.tokenId }); + ok = res.ok; + message = res.message; + } catch (e) { + message = (e as Error).message; + } + try { + await ctx.supabase.from('agent_actions').insert({ + user_id: ctx.userId, + agent_token_id: item.tokenId, + action: 'execute_order', + detail: { pair: item.pair, usd: item.usd, idempotencyKey: item.idempotencyKey, message }, + ok, + }); + } catch { /* non-fatal */ } + } + + // Remove only the keys we processed — re-read so orders enqueued mid-tick + // survive instead of being clobbered. + const processed = new Set(queue.map((q) => q.idempotencyKey)); + try { + const { data } = await ctx.supabase + .from('source_state') + .select('payload') + .eq('user_id', ctx.userId) + .eq('source_id', 'agent-orders') + .maybeSingle(); + const current = ((data?.payload as { queue?: AgentQueueItem[] } | undefined)?.queue ?? []) as AgentQueueItem[]; + const remaining = current.filter((q) => !processed.has(q.idempotencyKey)); + await ctx.supabase.from('source_state').upsert( + { user_id: ctx.userId, source_id: 'agent-orders', payload: { queue: remaining }, updated_at: new Date().toISOString() }, + { onConflict: 'user_id,source_id' }, + ); + } catch { /* non-fatal */ } +} + // Railway-level panic kill switch, captured at module-load time. The // per-tick applyEnvOverlay() copies user_settings.TRADING_ENABLED on top // of process.env, which would otherwise let a stale "true" in a user's @@ -116,7 +331,13 @@ export const cryptoTradeWorker: SourceWorker = { // (1) bypasses the per-tick user-config overlay, so a stale "true" // in user_settings.TRADING_ENABLED cannot mask the Railway panic // switch. (2)–(4) fall back to normal toggle behavior. - const uiSettings = await storage.get<{ tradingEnabled?: boolean | null; dailyLossLimitPct?: number | null }>('source-state', 'crypto-ui-settings'); + const uiSettings = await storage.get<{ + tradingEnabled?: boolean | null; + dailyLossLimitPct?: number | null; + spendBudgetUsd?: number | null; + budgetWindow?: string | null; + maxPositionUsd?: number | null; + }>('source-state', 'crypto-ui-settings'); const uiOverride = uiSettings?.tradingEnabled; const envRaw = (process.env.TRADING_ENABLED ?? '').trim().toLowerCase(); const envOverride = envRaw === 'true' ? true : envRaw === 'false' ? false : null; @@ -138,6 +359,29 @@ export const cryptoTradeWorker: SourceWorker = { : null, ); + // Crypto spend budget — the rolling USD cap on buys (engine/AI/agent). + // Per-user, flows via crypto-ui-settings (NOT operator env), same channel + // as the daily loss limit above. + const budgetWindow: BudgetWindow = isBudgetWindow(uiSettings?.budgetWindow) ? uiSettings.budgetWindow : 'daily'; + setSpendBudget( + typeof uiSettings?.spendBudgetUsd === 'number' && Number.isFinite(uiSettings.spendBudgetUsd) && uiSettings.spendBudgetUsd > 0 + ? uiSettings.spendBudgetUsd + : null, + budgetWindow, + ); + setMaxPositionUsd( + typeof uiSettings?.maxPositionUsd === 'number' && Number.isFinite(uiSettings.maxPositionUsd) && uiSettings.maxPositionUsd > 0 + ? uiSettings.maxPositionUsd + : null, + ); + + // Seed the in-memory spent counter from the durable ledger once per user + // (so the budget survives daemon restarts). Then drain freshly-recorded + // buys into the ledger each tick. + await reconcileSpendLedger(ctx, storage, budgetWindow); + await reconcileAiAnalyzer(ctx); + await reconcileAgentOrders(ctx); + // Arm the DEX executor lazily, inside applyEnvOverlay, so the // user's keys + RPC URLs are visible on process.env. We retry // every tick until success — operators frequently add an RPC URL diff --git a/apps/web/src/app/api/agent/budget/route.ts b/apps/web/src/app/api/agent/budget/route.ts new file mode 100644 index 0000000..3e00811 --- /dev/null +++ b/apps/web/src/app/api/agent/budget/route.ts @@ -0,0 +1,12 @@ +import type { NextRequest } from 'next/server'; +import { authenticateAgent, unauthorized } from '@/lib/api-auth'; +import { getAgentBudget } from '@/lib/agent-trade'; + +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest) { + const agent = await authenticateAgent(req); + if (!agent) return unauthorized(); + const { status, body } = await getAgentBudget(agent); + return Response.json(body, { status }); +} diff --git a/apps/web/src/app/api/agent/mcp/route.test.ts b/apps/web/src/app/api/agent/mcp/route.test.ts new file mode 100644 index 0000000..d741e74 --- /dev/null +++ b/apps/web/src/app/api/agent/mcp/route.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const authenticateAgentMock = vi.fn(); +const placeAgentOrderMock = vi.fn(); +const getAgentBudgetMock = vi.fn(); +const getAgentPortfolioMock = vi.fn(); +const getAgentQuoteMock = vi.fn(); + +vi.mock('@/lib/api-auth', () => ({ + authenticateAgent: (...a: unknown[]) => authenticateAgentMock(...a), + // route imports the type only; provide a stub to satisfy the module + unauthorized: () => new Response(null, { status: 401 }), +})); +vi.mock('@/lib/agent-trade', () => ({ + placeAgentOrder: (...a: unknown[]) => placeAgentOrderMock(...a), + getAgentBudget: (...a: unknown[]) => getAgentBudgetMock(...a), + getAgentPortfolio: (...a: unknown[]) => getAgentPortfolioMock(...a), + getAgentQuote: (...a: unknown[]) => getAgentQuoteMock(...a), +})); + +async function importRoute() { + return (await import('./route.js')) as typeof import('./route.js'); +} + +function rpc(method: string, params?: unknown, id: unknown = 1, withAuth = true) { + return new Request('http://test.local/api/agent/mcp', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(withAuth ? { authorization: 'Bearer b1dz_agent_test' } : {}), + }, + body: JSON.stringify({ jsonrpc: '2.0', id, method, params }), + }); +} + +describe('MCP route (e2e)', () => { + beforeEach(() => { + vi.clearAllMocks(); + authenticateAgentMock.mockResolvedValue({ token: { scopes: ['read', 'trade:crypto'] }, scopes: ['read', 'trade:crypto'], tokenId: 't', userId: 'u', admin: {} }); + }); + + it('handles initialize without auth and advertises tool capability', async () => { + const { POST } = await importRoute(); + const res = await POST(rpc('initialize', {}, 1, false) as never); + const body = await res.json(); + expect(body.result.serverInfo.name).toBe('b1dz-agent'); + expect(body.result.capabilities.tools).toBeDefined(); + }); + + it('rejects a bad JSON-RPC envelope with 400', async () => { + const { POST } = await importRoute(); + const bad = new Request('http://test.local/api/agent/mcp', { + method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ foo: 'bar' }), + }); + const res = await POST(bad as never); + expect(res.status).toBe(400); + }); + + it('lists the four tools (requires auth)', async () => { + const { POST } = await importRoute(); + const res = await POST(rpc('tools/list') as never); + const body = await res.json(); + const names = (body.result.tools as Array<{ name: string }>).map((t) => t.name).sort(); + expect(names).toEqual(['get_budget', 'get_portfolio', 'get_quote', 'place_order']); + }); + + it('401s tool calls without a valid agent token', async () => { + authenticateAgentMock.mockResolvedValueOnce(null); + const { POST } = await importRoute(); + const res = await POST(rpc('tools/list', undefined, 1) as never); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error.code).toBe(-32001); + }); + + it('routes tools/call place_order through placeAgentOrder', async () => { + placeAgentOrderMock.mockResolvedValue({ status: 202, body: { status: 'accepted', idempotencyKey: 'k' } }); + const { POST } = await importRoute(); + const res = await POST(rpc('tools/call', { name: 'place_order', arguments: { pair: 'BTC-USD', usd: 25 } }) as never); + const body = await res.json(); + expect(placeAgentOrderMock).toHaveBeenCalledOnce(); + expect(placeAgentOrderMock.mock.calls[0]![1]).toMatchObject({ pair: 'BTC-USD', usd: 25 }); + // tool result is wrapped as MCP text content + const text = JSON.parse(body.result.content[0].text); + expect(text.status).toBe('accepted'); + expect(body.result.isError).toBe(false); + }); + + it('returns an isError tool result for an unknown tool', async () => { + const { POST } = await importRoute(); + const res = await POST(rpc('tools/call', { name: 'launch_missiles', arguments: {} }) as never); + const body = await res.json(); + expect(body.result.isError).toBe(true); + expect(JSON.parse(body.result.content[0].text).error).toMatch(/unknown tool/); + }); + + it('returns -32601 for an unknown method', async () => { + const { POST } = await importRoute(); + const res = await POST(rpc('resources/list') as never); + const body = await res.json(); + expect(body.error.code).toBe(-32601); + }); +}); diff --git a/apps/web/src/app/api/agent/mcp/route.ts b/apps/web/src/app/api/agent/mcp/route.ts new file mode 100644 index 0000000..580075a --- /dev/null +++ b/apps/web/src/app/api/agent/mcp/route.ts @@ -0,0 +1,116 @@ +import type { NextRequest } from 'next/server'; +import { authenticateAgent, type AuthedAgent } from '@/lib/api-auth'; +import { getAgentBudget, getAgentPortfolio, getAgentQuote, placeAgentOrder } from '@/lib/agent-trade'; + +export const dynamic = 'force-dynamic'; + +/** + * MCP server (the Base-MCP analog) — exposes b1dz crypto trading to external AI + * agents (Claude, ChatGPT, any MCP client) as tools, over MCP's JSON-RPC HTTP + * transport. Auth is the same scoped agent token (Authorization: Bearer + * b1dz_agent_…); every tool funnels through the shared policy in agent-trade.ts, + * so trades stay hard-capped by the token's sub-account budget. + */ + +const PROTOCOL_VERSION = '2024-11-05'; + +const TOOLS = [ + { + name: 'get_budget', + description: "Get this agent token's spend budget, amount spent this window, and remaining USD.", + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'get_portfolio', + description: 'Get the current crypto positions and daily P/L for the linked account.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + { + name: 'get_quote', + description: 'Get the latest known price for a trading pair (e.g. BTC-USD).', + inputSchema: { + type: 'object', + properties: { pair: { type: 'string', description: 'Trading pair, e.g. BTC-USD' } }, + required: ['pair'], + }, + }, + { + name: 'place_order', + description: 'Place a crypto BUY for the given USD amount, hard-capped by the token budget. Returns accepted/queued; the engine re-checks risk + budget before execution.', + inputSchema: { + type: 'object', + properties: { + pair: { type: 'string', description: 'Trading pair, e.g. BTC-USD' }, + usd: { type: 'number', description: 'USD notional to buy' }, + exchange: { type: 'string', description: 'Optional exchange hint (kraken|coinbase|binance-us|gemini)' }, + idempotencyKey: { type: 'string', description: 'Optional client key to dedupe retries' }, + }, + required: ['pair', 'usd'], + }, + }, +] as const; + +function rpcResult(id: unknown, result: unknown) { + return Response.json({ jsonrpc: '2.0', id, result }); +} +function rpcError(id: unknown, code: number, message: string, httpStatus = 200) { + return Response.json({ jsonrpc: '2.0', id, error: { code, message } }, { status: httpStatus }); +} +function toolText(payload: unknown, isError = false) { + return { content: [{ type: 'text', text: JSON.stringify(payload) }], isError }; +} + +async function runTool(agent: AuthedAgent, name: string, args: Record) { + switch (name) { + case 'get_budget': return (await getAgentBudget(agent)).body; + case 'get_portfolio': return (await getAgentPortfolio(agent)).body; + case 'get_quote': return (await getAgentQuote(agent, String(args.pair ?? ''))).body; + case 'place_order': + return (await placeAgentOrder(agent, { + pair: String(args.pair ?? ''), + usd: Number(args.usd), + exchange: typeof args.exchange === 'string' ? args.exchange : undefined, + idempotencyKey: typeof args.idempotencyKey === 'string' ? args.idempotencyKey : undefined, + })).body; + default: + throw new Error(`unknown tool: ${name}`); + } +} + +export async function POST(req: NextRequest) { + const msg = (await req.json().catch(() => null)) as { jsonrpc?: string; id?: unknown; method?: string; params?: Record } | null; + if (!msg || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') { + return rpcError(msg?.id ?? null, -32600, 'invalid JSON-RPC request', 400); + } + + // `initialize` and notifications don't require auth to negotiate; tool calls do. + if (msg.method === 'initialize') { + return rpcResult(msg.id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'b1dz-agent', version: '1.0.0' }, + }); + } + if (msg.method === 'notifications/initialized' || msg.method === 'ping') { + return msg.id === undefined ? new Response(null, { status: 202 }) : rpcResult(msg.id, {}); + } + + const agent = await authenticateAgent(req); + if (!agent) return rpcError(msg.id ?? null, -32001, 'unauthorized: provide a valid b1dz_agent_ token', 401); + + if (msg.method === 'tools/list') { + return rpcResult(msg.id, { tools: TOOLS }); + } + if (msg.method === 'tools/call') { + const name = String(msg.params?.name ?? ''); + const args = (msg.params?.arguments as Record) ?? {}; + try { + const out = await runTool(agent, name, args); + return rpcResult(msg.id, toolText(out)); + } catch (e) { + return rpcResult(msg.id, toolText({ error: (e as Error).message }, true)); + } + } + + return rpcError(msg.id ?? null, -32601, `method not found: ${msg.method}`); +} diff --git a/apps/web/src/app/api/agent/orders/route.ts b/apps/web/src/app/api/agent/orders/route.ts new file mode 100644 index 0000000..c693cfd --- /dev/null +++ b/apps/web/src/app/api/agent/orders/route.ts @@ -0,0 +1,29 @@ +import type { NextRequest } from 'next/server'; +import { authenticateAgent, unauthorized } from '@/lib/api-auth'; +import { placeAgentOrder } from '@/lib/agent-trade'; + +export const dynamic = 'force-dynamic'; + +/** + * Place a crypto BUY on the user's behalf, hard-capped by the token's budget. + * The order is authorized + enqueued here; the daemon's engine re-checks risk + * and the global spend budget before actually placing it on the exchange. + */ +export async function POST(req: NextRequest) { + const agent = await authenticateAgent(req); + if (!agent) return unauthorized(); + const body = (await req.json().catch(() => null)) as { + pair?: string; + exchange?: string; + usd?: number; + idempotencyKey?: string; + } | null; + if (!body) return Response.json({ error: 'invalid JSON body' }, { status: 400 }); + const result = await placeAgentOrder(agent, { + pair: body.pair ?? '', + exchange: body.exchange, + usd: Number(body.usd), + idempotencyKey: body.idempotencyKey, + }); + return Response.json(result.body, { status: result.status }); +} diff --git a/apps/web/src/app/api/agent/portfolio/route.ts b/apps/web/src/app/api/agent/portfolio/route.ts new file mode 100644 index 0000000..1853ae3 --- /dev/null +++ b/apps/web/src/app/api/agent/portfolio/route.ts @@ -0,0 +1,14 @@ +import type { NextRequest } from 'next/server'; +import { authenticateAgent, unauthorized } from '@/lib/api-auth'; +import { getAgentPortfolio } from '@/lib/agent-trade'; +import { tokenHasScope } from '@b1dz/core'; + +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest) { + const agent = await authenticateAgent(req); + if (!agent) return unauthorized(); + if (!tokenHasScope(agent.token, 'read')) return Response.json({ error: 'token lacks read scope' }, { status: 403 }); + const { status, body } = await getAgentPortfolio(agent); + return Response.json(body, { status }); +} diff --git a/apps/web/src/app/api/agent/quote/route.ts b/apps/web/src/app/api/agent/quote/route.ts new file mode 100644 index 0000000..6fd3332 --- /dev/null +++ b/apps/web/src/app/api/agent/quote/route.ts @@ -0,0 +1,13 @@ +import type { NextRequest } from 'next/server'; +import { authenticateAgent, unauthorized } from '@/lib/api-auth'; +import { getAgentQuote } from '@/lib/agent-trade'; + +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest) { + const agent = await authenticateAgent(req); + if (!agent) return unauthorized(); + const pair = new URL(req.url).searchParams.get('pair') ?? ''; + const { status, body } = await getAgentQuote(agent, pair); + return Response.json(body, { status }); +} diff --git a/apps/web/src/app/api/agent/tokens/[id]/route.ts b/apps/web/src/app/api/agent/tokens/[id]/route.ts new file mode 100644 index 0000000..7ffa154 --- /dev/null +++ b/apps/web/src/app/api/agent/tokens/[id]/route.ts @@ -0,0 +1,18 @@ +import type { NextRequest } from 'next/server'; +import { authenticate, unauthorized } from '@/lib/api-auth'; + +export const dynamic = 'force-dynamic'; + +/** Revoke an agent token (soft delete — sets revoked_at). RLS scopes to owner. */ +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const auth = await authenticate(req); + if (!auth) return unauthorized(); + const { id } = await params; + const { error } = await auth.client + .from('agent_tokens') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', id) + .eq('user_id', auth.userId); + if (error) return Response.json({ error: error.message }, { status: 500 }); + return Response.json({ revoked: id }); +} diff --git a/apps/web/src/app/api/agent/tokens/route.ts b/apps/web/src/app/api/agent/tokens/route.ts new file mode 100644 index 0000000..22a4565 --- /dev/null +++ b/apps/web/src/app/api/agent/tokens/route.ts @@ -0,0 +1,63 @@ +import type { NextRequest } from 'next/server'; +import { authenticate, unauthorized } from '@/lib/api-auth'; +import { generateAgentToken } from '@/lib/agent-tokens'; +import { sanitizeScopes } from '@b1dz/core'; + +export const dynamic = 'force-dynamic'; + +/** List the current user's agent tokens (never returns the plaintext or hash). */ +export async function GET(req: NextRequest) { + const auth = await authenticate(req); + if (!auth) return unauthorized(); + const { data, error } = await auth.client + .from('agent_tokens') + .select('id, name, token_suffix, scopes, budget_usd, budget_window, allowed_symbols, revoked_at, last_used_at, created_at') + .order('created_at', { ascending: false }); + if (error) return Response.json({ error: error.message }, { status: 500 }); + return Response.json({ tokens: data ?? [] }); +} + +/** + * Create a new agent token. The plaintext is returned ONCE here and never + * again — only its sha-256 hash is stored. The caller copies it into their + * agent/MCP client. + */ +export async function POST(req: NextRequest) { + const auth = await authenticate(req); + if (!auth) return unauthorized(); + const body = (await req.json().catch(() => null)) as { + name?: unknown; + scopes?: unknown; + budgetUsd?: unknown; + budgetWindow?: unknown; + allowedSymbols?: unknown; + } | null; + + const name = typeof body?.name === 'string' && body.name.trim() ? body.name.trim().slice(0, 80) : 'agent'; + const scopes = sanitizeScopes(body?.scopes); + const budgetUsd = Number(body?.budgetUsd); + const budgetWindow = ['daily', 'weekly', 'monthly'].includes(String(body?.budgetWindow)) ? String(body?.budgetWindow) : 'daily'; + const allowedSymbols = Array.isArray(body?.allowedSymbols) + ? body.allowedSymbols.filter((s): s is string => typeof s === 'string').map((s) => s.toUpperCase()) + : null; + + const { plaintext, hash, suffix } = generateAgentToken(); + const { data, error } = await auth.client + .from('agent_tokens') + .insert({ + user_id: auth.userId, + name, + token_hash: hash, + token_suffix: suffix, + scopes, + budget_usd: Number.isFinite(budgetUsd) && budgetUsd > 0 ? budgetUsd : 0, + budget_window: budgetWindow, + allowed_symbols: allowedSymbols && allowedSymbols.length > 0 ? allowedSymbols : null, + }) + .select('id, name, token_suffix, scopes, budget_usd, budget_window, allowed_symbols, created_at') + .single(); + if (error) return Response.json({ error: error.message }, { status: 500 }); + + // token returned exactly once + return Response.json({ token: plaintext, record: data }); +} diff --git a/apps/web/src/app/settings/sections/agents.tsx b/apps/web/src/app/settings/sections/agents.tsx new file mode 100644 index 0000000..ce43314 --- /dev/null +++ b/apps/web/src/app/settings/sections/agents.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { SectionShell } from '../shared'; + +interface TokenRecord { + id: string; + name: string; + token_suffix: string; + scopes: string[]; + budget_usd: number; + budget_window: string; + allowed_symbols: string[] | null; + revoked_at: string | null; + last_used_at: string | null; + created_at: string; +} + +const ALL_SCOPES = ['read', 'trade:crypto', 'trade:equity'] as const; + +/** + * Agents (the "Coinbase for Agents" sub-accounts). Create scoped tokens that an + * external AI (Claude, ChatGPT, an MCP client) presents to trade on your + * behalf, each hard-capped by its own spend budget. The MCP endpoint is + * `/api/agent/mcp`; the REST surface is under `/api/agent/*`. + */ +export function AgentsSection() { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [name, setName] = useState('my-agent'); + const [scopes, setScopes] = useState(['read', 'trade:crypto']); + const [budget, setBudget] = useState('50'); + const [window, setWindow] = useState('daily'); + const [symbols, setSymbols] = useState(''); + const [created, setCreated] = useState(null); + const [error, setError] = useState(null); + + const refresh = async () => { + setLoading(true); + const res = await fetch('/api/agent/tokens', { cache: 'no-store' }).catch(() => null); + const body = res?.ok ? ((await res.json()) as { tokens: TokenRecord[] }) : null; + setTokens(body?.tokens ?? []); + setLoading(false); + }; + + useEffect(() => { void refresh(); }, []); + + const toggleScope = (s: string) => + setScopes((cur) => (cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s])); + + const onCreate = async () => { + setError(null); + setCreated(null); + const res = await fetch('/api/agent/tokens', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + name, + scopes, + budgetUsd: Number(budget), + budgetWindow: window, + allowedSymbols: symbols.trim() ? symbols.split(',').map((s) => s.trim()).filter(Boolean) : null, + }), + }).catch(() => null); + const body = res ? ((await res.json().catch(() => null)) as { token?: string; error?: string } | null) : null; + if (!res?.ok || !body?.token) { + setError(body?.error ?? 'failed to create token'); + return; + } + setCreated(body.token); + await refresh(); + }; + + const onRevoke = async (id: string) => { + await fetch(`/api/agent/tokens/${id}`, { method: 'DELETE' }).catch(() => null); + await refresh(); + }; + + return ( +
+

+ Agent tokens let an external AI place crypto trades within a fixed spend budget — a sub-account limit. Connect an MCP + client to /api/agent/mcp with the token as a Bearer credential. A + token is shown once at creation; store it securely. Revoke anytime. +

+ + +
+ +
+ Scopes +
+ {ALL_SCOPES.map((s) => ( + + ))} +
+
+
+ + +
+ +
+
+ + {error &&

{error}

} + {created && ( +
+

Token created — copy it now, it won't be shown again:

+ {created} +
+ )} + +
+
+

Active tokens

+

Revoking is immediate and cannot be undone.

+
+ {loading &&

loading…

} + {!loading && tokens.length === 0 &&

No agent tokens yet.

} +
+ {tokens.filter((t) => !t.revoked_at).map((t) => ( +
+
+
{t.name} …{t.token_suffix}
+
+ ${Number(t.budget_usd).toFixed(0)}/{t.budget_window} · {t.scopes.join(', ')} + {t.allowed_symbols?.length ? ` · ${t.allowed_symbols.join(',')}` : ' · any symbol'} +
+
+ +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/src/app/settings/sections/ai-analyzer.tsx b/apps/web/src/app/settings/sections/ai-analyzer.tsx new file mode 100644 index 0000000..3dc7e26 --- /dev/null +++ b/apps/web/src/app/settings/sections/ai-analyzer.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + BoolRow, + NumberRow, + SecretRow, + SectionShell, + decryptSecretBlob, + readPlainBool, + readPlainNumber, + readPlainString, + saveSettings, + type SettingsResponse, +} from '../shared'; + +const SECRET_FIELDS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'] as const; +type SecretField = typeof SECRET_FIELDS[number]; + +/** + * AI Analyzer (the "Coinbase Advisor" analog). b1dz calls OUT to the user's + * OWN model for regime/setup scoring, overlaid on the deterministic engine — + * still hard-capped by the crypto spend budget. + * + * BYO per-user key: Anthropic/OpenAI are API-key (not consumer-OAuth) for + * programmatic inference, so the key is pasted and stored in the same + * encrypted secret blob as exchange keys, read STRICTLY per-user by the daemon + * (never an operator fallback — that's both the env-leak and the shared-key + * single-point-of-failure we already learned about). + */ +export function AiAnalyzerSection({ + data, + cryptoKey, + onSaved, +}: { + data: SettingsResponse; + cryptoKey: CryptoKey | null; + onSaved: (next: SettingsResponse) => void; +}) { + const cryptoUnavailable = !cryptoKey; + const [enabled, setEnabled] = useState(readPlainBool(data, 'AI_ANALYZER_ENABLED')); + const [provider, setProvider] = useState<'anthropic' | 'openai'>( + readPlainString(data, 'AI_PROVIDER') === 'openai' ? 'openai' : 'anthropic', + ); + const [maxCalls, setMaxCalls] = useState(readPlainNumber(data, 'AI_MAX_CALLS_PER_MIN')); + + const [drafts, setDrafts] = useState>>({}); + const [pendingClear, setPendingClear] = useState>>({}); + const [decrypted, setDecrypted] = useState | null>(null); + const [revealed, setRevealed] = useState>>({}); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const plain = await decryptSecretBlob(cryptoKey, data.cipher); + if (!cancelled) setDecrypted(plain); + } catch { + if (!cancelled) setDecrypted({}); + } + })(); + return () => { cancelled = true; }; + }, [cryptoKey, data.cipher]); + + const setDraft = (k: SecretField) => (v: string) => setDrafts((d) => ({ ...d, [k]: v })); + const clearField = (k: SecretField) => () => { + setPendingClear((p) => ({ ...p, [k]: true })); + setDrafts((d) => ({ ...d, [k]: '' })); + setRevealed((r) => { const n = { ...r }; delete n[k]; return n; }); + }; + const revealField = (k: SecretField) => async () => { + if (!cryptoKey) throw new Error('encryption key not loaded'); + if (!decrypted) setDecrypted(await decryptSecretBlob(cryptoKey, data.cipher)); + setRevealed((r) => ({ ...r, [k]: true })); + }; + + const num = (s: string) => (s.trim() === '' ? null : Number(s)); + + const onSave = async () => { + const merged: Record = { ...(decrypted ?? {}) }; + for (const f of SECRET_FIELDS) { + if (pendingClear[f]) delete merged[f]; + else if ((drafts[f] ?? '').trim() !== '') merged[f] = drafts[f]!; + } + const next = await saveSettings( + { + plain: { + AI_ANALYZER_ENABLED: enabled, + AI_PROVIDER: provider, + AI_MAX_CALLS_PER_MIN: num(maxCalls), + }, + secret: Object.keys(merged).length > 0 ? merged : null, + }, + { cryptoKey }, + ); + onSaved(next); + setDrafts({}); + setPendingClear({}); + setRevealed({}); + setDecrypted(merged); + }; + + const secretRow = (field: SecretField, label: string, hint?: string) => { + const stored = decrypted?.[field]; + const isSet = !!stored; + return ( + + ); + }; + + return ( +
+

+ Connect your own Claude or ChatGPT API key. b1dz uses it to score market regime/setups and overlay that on the + deterministic strategies — it can size a buy up within your spend budget, never + beyond it. Your key is encrypted in your browser and used only for your account. +

+ + + +
+ + +
+ +
+ + + {secretRow('ANTHROPIC_API_KEY', 'Anthropic API key', 'sk-ant-… from console.anthropic.com')} + {secretRow('OPENAI_API_KEY', 'OpenAI API key', 'sk-… from platform.openai.com')} + +
+ ); +} diff --git a/apps/web/src/app/settings/sections/crypto-budget.tsx b/apps/web/src/app/settings/sections/crypto-budget.tsx new file mode 100644 index 0000000..9acfb4c --- /dev/null +++ b/apps/web/src/app/settings/sections/crypto-budget.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { NumberRow, SectionShell } from '../shared'; +import { putUiSettings } from '@/lib/use-source-state'; +import type { UiSettings } from '@/lib/source-state-types'; + +type Window = 'daily' | 'weekly' | 'monthly'; + +/** + * Crypto spend budget — the rolling USD cap on BUYS that every order (engine, + * AI analyzer, or external agent) is hard-capped against. Lives in + * `crypto-ui-settings` alongside the daily-loss limit (read per-tick by the + * daemon's crypto-trade worker — NOT operator env), so it stays per-user. + * + * The generic storage PUT replaces the whole row, so we fetch the current + * settings first and merge our fields to avoid clobbering tradingEnabled / + * dailyLossLimitPct. + */ +export function CryptoBudgetSection() { + const [loaded, setLoaded] = useState(null); + const [budget, setBudget] = useState(''); + const [window, setWindow] = useState('daily'); + const [maxPos, setMaxPos] = useState(''); + const [status, setStatus] = useState(null); + + useEffect(() => { + void (async () => { + const res = await fetch('/api/storage/source-state/crypto-ui-settings', { cache: 'no-store' }).catch(() => null); + const body = res?.ok ? ((await res.json().catch(() => null)) as { value?: UiSettings } | null) : null; + const cur = body?.value ?? {}; + setLoaded(cur); + if (typeof cur.spendBudgetUsd === 'number') setBudget(String(cur.spendBudgetUsd)); + if (cur.budgetWindow === 'daily' || cur.budgetWindow === 'weekly' || cur.budgetWindow === 'monthly') setWindow(cur.budgetWindow); + if (typeof cur.maxPositionUsd === 'number') setMaxPos(String(cur.maxPositionUsd)); + })(); + }, []); + + const num = (s: string) => (s.trim() === '' ? null : Number(s)); + + const onSave = async () => { + setStatus(null); + // Re-fetch immediately before writing to minimize clobbering a concurrent + // toggle change (the storage row is shared with tradingEnabled etc.). + const res = await fetch('/api/storage/source-state/crypto-ui-settings', { cache: 'no-store' }).catch(() => null); + const body = res?.ok ? ((await res.json().catch(() => null)) as { value?: UiSettings } | null) : null; + const cur = body?.value ?? loaded ?? {}; + const ok = await putUiSettings({ + ...cur, + spendBudgetUsd: num(budget), + budgetWindow: window, + maxPositionUsd: num(maxPos), + }); + setStatus(ok ? 'Saved.' : 'Save failed — try again.'); + }; + + return ( +
+

+ The spend budget is the master cap on crypto buys. Every order — from the + deterministic engine, the AI analyzer, or a connected agent — is hard-capped against it. Leave the budget blank for no + cap (the per-position size still applies). +

+ + + +
+ + +
+ +
+ + {status &&

{status}

} +
+ ); +} diff --git a/apps/web/src/app/settings/settings-client.tsx b/apps/web/src/app/settings/settings-client.tsx index abf131a..5672ed2 100644 --- a/apps/web/src/app/settings/settings-client.tsx +++ b/apps/web/src/app/settings/settings-client.tsx @@ -8,15 +8,21 @@ import { StrategiesSection } from './sections/strategies'; import { TogglesSection } from './sections/toggles'; import { PluginsSection } from './sections/plugins'; import { EquitiesSection } from './sections/equities'; +import { CryptoBudgetSection } from './sections/crypto-budget'; +import { AiAnalyzerSection } from './sections/ai-analyzer'; +import { AgentsSection } from './sections/agents'; import type { SettingsResponse } from './shared'; import { importKey } from '@/lib/browser-crypto'; -type Tab = 'plugins' | 'wallets' | 'equities' | 'solana' | 'thresholds' | 'strategies' | 'toggles'; +type Tab = 'plugins' | 'wallets' | 'equities' | 'budget' | 'ai' | 'agents' | 'solana' | 'thresholds' | 'strategies' | 'toggles'; const TABS: { id: Tab; label: string }[] = [ { id: 'plugins', label: 'Plugins' }, { id: 'wallets', label: 'Wallets' }, { id: 'equities', label: 'Equities' }, + { id: 'budget', label: 'Budget' }, + { id: 'ai', label: 'AI Analyzer' }, + { id: 'agents', label: 'Agents' }, { id: 'solana', label: 'Solana' }, { id: 'thresholds', label: 'Thresholds' }, { id: 'strategies', label: 'Strategies' }, @@ -135,6 +141,9 @@ export function SettingsClient() {
{tab === 'wallets' && } {tab === 'equities' && } + {tab === 'budget' && } + {tab === 'ai' && } + {tab === 'agents' && } {tab === 'solana' && } {tab === 'thresholds' && } {tab === 'strategies' && } diff --git a/apps/web/src/lib/agent-tokens.ts b/apps/web/src/lib/agent-tokens.ts new file mode 100644 index 0000000..5e3e193 --- /dev/null +++ b/apps/web/src/lib/agent-tokens.ts @@ -0,0 +1,34 @@ +/** + * Agent token web helpers — generation, hashing, and the per-token budget + * lookup. Server-side only (uses node:crypto + the admin Supabase client). + * Policy math (scopes, allowlist, budget arithmetic) lives in @b1dz/core. + */ +import { createHash, randomBytes } from 'node:crypto'; +import { AGENT_TOKEN_PREFIX, agentWindowStart, type AgentBudgetWindow } from '@b1dz/core'; +import { createAdminSupabase } from './supabase'; + +export function generateAgentToken(): { plaintext: string; hash: string; suffix: string } { + const plaintext = AGENT_TOKEN_PREFIX + randomBytes(24).toString('base64url'); + return { plaintext, hash: hashAgentToken(plaintext), suffix: plaintext.slice(-4) }; +} + +export function hashAgentToken(plaintext: string): string { + return createHash('sha256').update(plaintext).digest('hex'); +} + +/** Sum this token's spend within the current window from the durable ledger. */ +export async function tokenSpentThisWindow( + tokenId: string, + window: AgentBudgetWindow, + now = Date.now(), +): Promise { + const admin = createAdminSupabase(); + const since = new Date(agentWindowStart(window, now)).toISOString(); + const { data, error } = await admin + .from('crypto_spend_ledger') + .select('usd') + .eq('agent_token_id', tokenId) + .gte('ts', since); + if (error) return 0; + return (data ?? []).reduce((acc: number, r: { usd: number | string }) => acc + Number(r.usd ?? 0), 0); +} diff --git a/apps/web/src/lib/agent-trade.test.ts b/apps/web/src/lib/agent-trade.test.ts new file mode 100644 index 0000000..0b1368f --- /dev/null +++ b/apps/web/src/lib/agent-trade.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentTokenRow } from '@b1dz/core'; + +// tokenSpentThisWindow hits the DB via createAdminSupabase — mock it so the +// per-token budget math is driven by the test. +const tokenSpentMock = vi.fn(); +vi.mock('./agent-tokens', () => ({ + tokenSpentThisWindow: (...args: unknown[]) => tokenSpentMock(...args), +})); + +import { placeAgentOrder, getAgentBudget, getAgentQuote } from './agent-trade.js'; +import type { AuthedAgent } from './api-auth.js'; + +/** Minimal fake admin client capturing source_state reads/writes + audit inserts. */ +function makeAdmin(initialQueue: unknown[] = [], cryptoPayload: Record | null = null) { + const state = { queue: initialQueue, upserts: [] as Array>, actions: [] as Array> }; + const admin = { + from(table: string) { + if (table === 'agent_actions') { + return { insert: async (row: Record) => { state.actions.push(row); return { error: null }; } }; + } + // source_state + return { + select: () => ({ + eq: () => ({ + eq: (_c: string, sourceId: string) => ({ + maybeSingle: async () => { + if (sourceId === 'agent-orders') return { data: { payload: { queue: state.queue } }, error: null }; + return { data: cryptoPayload ? { payload: cryptoPayload } : null, error: null }; + }, + }), + }), + }), + upsert: async (row: Record) => { + state.upserts.push(row); + const payload = row.payload as { queue?: unknown[] } | undefined; + if (payload?.queue) state.queue = payload.queue; + return { error: null }; + }, + }; + }, + }; + return { admin, state }; +} + +const token = (over: Partial = {}): AgentTokenRow => ({ + id: 'tok-1', + user_id: 'user-1', + name: 'bot', + token_hash: 'h', + token_suffix: 'abcd', + scopes: ['read', 'trade:crypto'], + budget_usd: 100, + budget_window: 'daily', + allowed_symbols: null, + revoked_at: null, + last_used_at: null, + created_at: '2026-06-18T00:00:00Z', + ...over, +}); + +function agent(over: Partial = {}, admin = makeAdmin().admin): AuthedAgent { + const t = token(over); + return { admin: admin as never, userId: t.user_id, tokenId: t.id, scopes: t.scopes, token: t }; +} + +describe('placeAgentOrder (contract)', () => { + beforeEach(() => { + vi.clearAllMocks(); + tokenSpentMock.mockResolvedValue(0); + }); + + it('enqueues a valid buy within budget (202 accepted)', async () => { + const { admin, state } = makeAdmin(); + const res = await placeAgentOrder(agent({}, admin), { pair: 'BTC-USD', usd: 50, idempotencyKey: 'k1' }); + expect(res.status).toBe(202); + expect(res.body.status).toBe('accepted'); + expect(res.body.remainingBudgetUsd).toBe(50); + // enqueued exactly one order + expect(state.queue).toHaveLength(1); + expect((state.queue[0] as { pair: string }).pair).toBe('BTC-USD'); + // audited + expect(state.actions.some((a) => a.action === 'place_order' && a.ok === true)).toBe(true); + }); + + it('rejects when the token lacks trade:crypto scope (403)', async () => { + const res = await placeAgentOrder(agent({ scopes: ['read'] }), { pair: 'BTC-USD', usd: 10 }); + expect(res.status).toBe(403); + expect(String(res.body.error)).toMatch(/scope/); + }); + + it('rejects a symbol outside the allowlist (403)', async () => { + const res = await placeAgentOrder(agent({ allowed_symbols: ['ETH-USD'] }), { pair: 'BTC-USD', usd: 10 }); + expect(res.status).toBe(403); + expect(String(res.body.error)).toMatch(/allowed symbols/); + }); + + it('rejects an order over the remaining per-token budget (402)', async () => { + tokenSpentMock.mockResolvedValue(80); + const res = await placeAgentOrder(agent({ budget_usd: 100 }), { pair: 'BTC-USD', usd: 50 }); + expect(res.status).toBe(402); + expect(res.body.remainingUsd).toBe(20); + }); + + it('rejects a zero-budget token (402)', async () => { + const res = await placeAgentOrder(agent({ budget_usd: 0 }), { pair: 'BTC-USD', usd: 1 }); + expect(res.status).toBe(402); + }); + + it('validates pair + positive usd (400)', async () => { + expect((await placeAgentOrder(agent(), { pair: '', usd: 10 })).status).toBe(400); + expect((await placeAgentOrder(agent(), { pair: 'BTC-USD', usd: 0 })).status).toBe(400); + }); + + it('is idempotent on the idempotency key (no double-enqueue)', async () => { + const { admin, state } = makeAdmin([ + { idempotencyKey: 'dup', tokenId: 'tok-1', pair: 'BTC-USD', exchange: null, usd: 50, side: 'buy', enqueuedAt: 'x' }, + ]); + const res = await placeAgentOrder(agent({}, admin), { pair: 'BTC-USD', usd: 50, idempotencyKey: 'dup' }); + expect(res.status).toBe(200); + expect(res.body.duplicate).toBe(true); + expect(state.queue).toHaveLength(1); // unchanged + }); + + it('normalizes the pair to uppercase', async () => { + const { admin, state } = makeAdmin(); + await placeAgentOrder(agent({}, admin), { pair: 'btc-usd', usd: 10, idempotencyKey: 'k' }); + expect((state.queue[0] as { pair: string }).pair).toBe('BTC-USD'); + }); +}); + +describe('getAgentBudget (contract)', () => { + beforeEach(() => { vi.clearAllMocks(); }); + it('reports budget/spent/remaining for the window', async () => { + tokenSpentMock.mockResolvedValue(30); + const res = await getAgentBudget(agent({ budget_usd: 100, budget_window: 'weekly' })); + expect(res.body).toMatchObject({ budgetUsd: 100, spentUsd: 30, remainingUsd: 70, window: 'weekly' }); + }); +}); + +describe('getAgentQuote (contract)', () => { + it('returns the live price when the pair is in the active set', async () => { + const { admin } = makeAdmin([], { trade: { positions: [{ pair: 'BTC-USD', exchange: 'kraken', currentPrice: 65000 }] } }); + const res = await getAgentQuote(agent({}, admin), 'btc-usd'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ pair: 'BTC-USD', exchange: 'kraken', price: 65000 }); + }); + + it('404s when the pair has no live price', async () => { + const { admin } = makeAdmin([], { trade: { positions: [] } }); + const res = await getAgentQuote(agent({}, admin), 'DOGE-USD'); + expect(res.status).toBe(404); + }); +}); diff --git a/apps/web/src/lib/agent-trade.ts b/apps/web/src/lib/agent-trade.ts new file mode 100644 index 0000000..55ea4b8 --- /dev/null +++ b/apps/web/src/lib/agent-trade.ts @@ -0,0 +1,161 @@ +/** + * Agent trading + read helpers. Every agent action funnels through here so the + * REST route and the MCP server enforce the SAME policy: scope → symbol + * allowlist → per-token budget → enqueue for the daemon → audit log. Order + * EXECUTION happens in the daemon (which re-applies the engine's own spend + * budget + risk guards); this layer authorizes and enqueues. + */ +import { + checkAgentBudget, + symbolAllowed, + tokenHasScope, + type AgentBudgetWindow, +} from '@b1dz/core'; +import { tokenSpentThisWindow } from './agent-tokens'; +import type { AuthedAgent } from './api-auth'; + +export interface AgentOrderRequest { + pair: string; + exchange?: string; + usd: number; + idempotencyKey?: string; +} + +export interface AgentOrderResult { + status: number; + body: Record; +} + +async function logAction(agent: AuthedAgent, action: string, detail: Record, ok: boolean) { + try { + await agent.admin.from('agent_actions').insert({ + user_id: agent.userId, + agent_token_id: agent.tokenId, + action, + detail, + ok, + }); + } catch { /* non-fatal */ } +} + +export async function getAgentBudget(agent: AuthedAgent): Promise { + const window = (agent.token.budget_window as AgentBudgetWindow) ?? 'daily'; + const spent = await tokenSpentThisWindow(agent.tokenId, window); + const check = checkAgentBudget(agent.token, spent, 0); + return { + status: 200, + body: { + budgetUsd: check.budgetUsd, + spentUsd: check.spentUsd, + remainingUsd: check.remainingUsd, + window, + scopes: agent.scopes, + allowedSymbols: agent.token.allowed_symbols ?? null, + }, + }; +} + +async function readCryptoState(agent: AuthedAgent): Promise | null> { + const { data } = await agent.admin + .from('source_state') + .select('payload') + .eq('user_id', agent.userId) + .eq('source_id', 'crypto-trade') + .maybeSingle(); + return (data?.payload as Record) ?? null; +} + +export async function getAgentPortfolio(agent: AuthedAgent): Promise { + const payload = await readCryptoState(agent); + const trade = (payload?.trade ?? payload) as Record | undefined; + return { + status: 200, + body: { + positions: (trade?.positions as unknown[]) ?? [], + dailyPnl: trade?.dailyPnl ?? null, + dailyPnlPct: trade?.dailyPnlPct ?? null, + }, + }; +} + +export async function getAgentQuote(agent: AuthedAgent, pair: string): Promise { + if (!pair) return { status: 400, body: { error: 'pair required' } }; + const payload = await readCryptoState(agent); + const trade = (payload?.trade ?? payload) as { positions?: Array<{ pair: string; exchange: string; currentPrice: number }> } | undefined; + const pos = (trade?.positions ?? []).find((p) => p.pair.toUpperCase() === pair.toUpperCase()); + if (!pos) return { status: 404, body: { error: `no live price for ${pair} (not in active set)`, pair } }; + return { status: 200, body: { pair: pos.pair, exchange: pos.exchange, price: pos.currentPrice } }; +} + +/** + * Authorize + enqueue a BUY for the daemon. Enforces scope, symbol allowlist, + * and the per-token budget. Idempotent on `idempotencyKey` within the queue. + */ +export async function placeAgentOrder(agent: AuthedAgent, req: AgentOrderRequest): Promise { + if (!tokenHasScope(agent.token, 'trade:crypto')) { + await logAction(agent, 'place_order', { reason: 'missing trade:crypto scope' }, false); + return { status: 403, body: { error: 'token lacks trade:crypto scope' } }; + } + const pair = String(req.pair ?? '').toUpperCase(); + const usd = Number(req.usd); + if (!pair || !Number.isFinite(usd) || usd <= 0) { + return { status: 400, body: { error: 'pair and positive usd are required' } }; + } + if (!symbolAllowed(agent.token, pair)) { + await logAction(agent, 'place_order', { pair, reason: 'symbol not in allowlist' }, false); + return { status: 403, body: { error: `${pair} is not in this token's allowed symbols` } }; + } + + const window = (agent.token.budget_window as AgentBudgetWindow) ?? 'daily'; + const spent = await tokenSpentThisWindow(agent.tokenId, window); + const budget = checkAgentBudget(agent.token, spent, usd); + if (!budget.allowed) { + await logAction(agent, 'place_order', { pair, usd, reason: budget.reason }, false); + return { status: 402, body: { error: budget.reason, remainingUsd: budget.remainingUsd } }; + } + + // Enqueue for the daemon's crypto-trade worker. Idempotent on the key. + const { data: row } = await agent.admin + .from('source_state') + .select('payload') + .eq('user_id', agent.userId) + .eq('source_id', 'agent-orders') + .maybeSingle(); + const queue = ((row?.payload as { queue?: AgentQueueItem[] } | undefined)?.queue ?? []) as AgentQueueItem[]; + const key = req.idempotencyKey ?? `${agent.tokenId}:${Date.now()}`; + if (queue.some((q) => q.idempotencyKey === key)) { + return { status: 200, body: { status: 'accepted', idempotencyKey: key, duplicate: true } }; + } + const item: AgentQueueItem = { + idempotencyKey: key, + tokenId: agent.tokenId, + pair, + exchange: req.exchange ?? null, + usd, + side: 'buy', + enqueuedAt: new Date().toISOString(), + }; + // keep the queue bounded + const nextQueue = [...queue.slice(-49), item]; + const { error } = await agent.admin.from('source_state').upsert( + { user_id: agent.userId, source_id: 'agent-orders', payload: { queue: nextQueue }, updated_at: new Date().toISOString() }, + { onConflict: 'user_id,source_id' }, + ); + if (error) return { status: 500, body: { error: error.message } }; + + await logAction(agent, 'place_order', { pair, usd, idempotencyKey: key, remainingUsd: budget.remainingUsd - usd }, true); + return { + status: 202, + body: { status: 'accepted', idempotencyKey: key, pair, usd, remainingBudgetUsd: budget.remainingUsd - usd, note: 'queued for execution; the engine re-checks risk + budget before placing' }, + }; +} + +export interface AgentQueueItem { + idempotencyKey: string; + tokenId: string; + pair: string; + exchange: string | null; + usd: number; + side: 'buy'; + enqueuedAt: string; +} diff --git a/apps/web/src/lib/api-auth.ts b/apps/web/src/lib/api-auth.ts index e2836e9..0d4d108 100644 --- a/apps/web/src/lib/api-auth.ts +++ b/apps/web/src/lib/api-auth.ts @@ -53,3 +53,51 @@ export async function authenticate(req: NextRequest): Promise { + const auth = req.headers.get('authorization'); + if (!auth?.startsWith('Bearer ')) return null; + const raw = auth.slice(7); + const { isAgentToken } = await import('@b1dz/core'); + if (!isAgentToken(raw)) return null; + + const { hashAgentToken } = await import('./agent-tokens'); + const { createAdminSupabase } = await import('./supabase'); + const admin = createAdminSupabase(); + const hash = hashAgentToken(raw); + const { data, error } = await admin + .from('agent_tokens') + .select('*') + .eq('token_hash', hash) + .is('revoked_at', null) + .maybeSingle(); + if (error || !data) return null; + + // best-effort last_used_at touch (don't block on it) + void admin.from('agent_tokens').update({ last_used_at: new Date().toISOString() }).eq('id', data.id); + + return { + admin, + userId: data.user_id, + tokenId: data.id, + scopes: Array.isArray(data.scopes) ? data.scopes : [], + token: data as import('@b1dz/core').AgentTokenRow, + }; +} diff --git a/apps/web/src/lib/source-state-types.ts b/apps/web/src/lib/source-state-types.ts index 63a95d0..0f53d14 100644 --- a/apps/web/src/lib/source-state-types.ts +++ b/apps/web/src/lib/source-state-types.ts @@ -164,6 +164,12 @@ export interface ArbPipelineState { export interface UiSettings { tradingEnabled?: boolean | null; dailyLossLimitPct?: number | null; + /** Rolling crypto spend (buy) budget in USD. null/0 = unlimited. */ + spendBudgetUsd?: number | null; + /** Window the spend budget resets on. */ + budgetWindow?: 'daily' | 'weekly' | 'monthly' | null; + /** Per-user override of the per-position cap (USD). null = engine default. */ + maxPositionUsd?: number | null; } export interface PumpfunPosition { diff --git a/docs/prd-ai-agents-v1.md b/docs/prd-ai-agents-v1.md new file mode 100644 index 0000000..4ec1d9a --- /dev/null +++ b/docs/prd-ai-agents-v1.md @@ -0,0 +1,200 @@ +# PRD — AI Analyzers & Agent Trading ("Coinbase for Agents" analog) — v1 + +## 0. Inspiration + +Coinbase shipped three distinct things bundled in one announcement: + +1. **Coinbase Advisor** — an AI investment advisor (AI *gives advice*). +2. **Coinbase for Agents** — external AI systems (Claude, ChatGPT) *connect to + accounts and trade within defined sub-account limits*. +3. **Base MCP + x402** — the agent-wallet plumbing (MCP tool surface + a + spend/pay protocol). + +This PRD maps all three onto b1dz. The unifying safety primitive is a +**server-side spend budget + ledger** that every AI- or agent-initiated order is +hard-capped against. Authority model (decided): **execute within budget** — AI +and agents may place real orders, but only inside the budget and the existing +risk guards. + +## 1. Why this fits b1dz cleanly + +b1dz already has the load-bearing infrastructure: + +- **OAuth/secret linking** — `packages/core/src/oauth.ts` registry + + `/api/oauth/[plugin]/start|callback`, AES-256-GCM secret blob in + `user_settings`, daemon 24/7 token auto-refresh. (See [[project-b1dz-equities-v1]].) +- **Strict per-user secrets** — `getUserSecret`/`getUserPlain` with NO operator + env fallback, after the multi-tenant leak fix. AI keys MUST use this path. + (See [[project-b1dz-security-env-fallback]].) +- **A real risk engine** — `@b1dz/equity-engine` `decideEquityOrder()` enforces + per-trade / position / overnight caps deterministically. +- **Bearer-token API auth** — `apps/web/src/lib/api-auth.ts` already accepts + `Authorization: Bearer`, so a scoped agent token is an extension, not a rebuild. +- **Per-user runtime config channel into the crypto engine** — the daemon's + `apps/daemon/src/sources/crypto-trade.ts` reads UI settings from `source_state` + (`crypto-ui-settings`) and injects them into the engine via runtime setters + (e.g. `setDailyLossLimitPct`, engine `packages/source-crypto-trade/src/index.ts:1749`). + The spend budget rides this exact channel — **never env**. +- **AI is already the named v2 path** — `PRD-v1-cex-analysis-engine.md` §2 calls + out "AI-assisted regime classification" as the intended upgrade. + +## 2. Reality check: "OAuth for Anthropic / ChatGPT" + +Anthropic and OpenAI are **API-key**, not consumer-OAuth, for programmatic +inference. So "connect your Claude/ChatGPT account" is **paste-an-API-key**, +stored via the same encrypted secret blob the brokers use as their paste +fallback. We add OAuth registry entries only if/where a provider exposes a real +OAuth-for-API flow; otherwise paste is the path. + +**Hard rule (two prior incidents):** keys are **BYO per-user**, read via strict +`getUserSecret`. Never a shared operator key — that's both the env-fallback leak +*and* the CrawlProof OpenAI single-point-of-failure. (See +[[project-crawlproof-autoblog-openai-spof]].) + +--- + +## 3. Phase 1 — Crypto spend budget + ledger (FOUNDATION) + +The chokepoint everything else enforces against. Small, high-value, ships alone. + +### 3.1 Settings (per-user, plain fields — mirror equities) +- `CRYPTO_SPEND_BUDGET_USD` — rolling spend cap (buys) over a window. +- `CRYPTO_BUDGET_WINDOW` — `daily` | `weekly` | `monthly` (default `daily`). +- `CRYPTO_MAX_POSITION_USD` — replaces the hardcoded `$100` (`MAX_POSITION_USD`), + per-user. +- Keep existing `dailyLossLimitPct` (already per-user via `crypto-ui-settings`). + +New settings section `apps/web/src/app/settings/sections/crypto-budget.tsx` +(clone the structure of `sections/equities.tsx`), wired in `settings-client.tsx`. + +### 3.2 Engine enforcement (`packages/source-crypto-trade/src/index.ts`) +- Add a `dailySpentUsd` accumulator alongside the existing `dailyFees` / + `trackedDailyPnl` / `dailyEquityBaselineUsd` daily counters, reset on the same + day-rollover. +- Add runtime setters `setSpendBudgetUsd(usd, window)` / `setMaxPositionUsd(usd)` + following the exact `setDailyLossLimitPct` pattern (`index.ts:1749`). +- New guard `budgetWouldExceed(orderUsd)` checked before every BUY, next to the + daily-loss-limit check (`isDailyLossLimitHit`, `index.ts:1763`). Returns a + structured reason for logs/UI ("budget: spent $X of $Y this window"). +- Record spend on fill (where `recordDailyFee` is called, `index.ts:264`). + +### 3.3 Daemon wiring (`apps/daemon/src/sources/crypto-trade.ts`) +- Extend the `crypto-ui-settings` read (~`:119`) to include the new budget + fields, sourced from the user's strict settings, and call the new runtime + setters each tick — same place `dailyLossLimitPct` is applied (~`:136`). + +### 3.4 Persistence / ledger +- Migration `supabase/migrations/_crypto_spend_ledger.sql`: + `crypto_spend_ledger(user_id, ts, source, asset, exchange, usd, agent_token_id + nullable)` — RLS owner-only (auto-RLS trigger covers new tables). + - `source` ∈ `engine` | `ai` | `agent` so the same ledger backs Phases 2–3. +- The daemon writes a ledger row per executed buy; the budget window is summed + from this ledger (survives daemon restarts, unlike an in-memory counter). +- Expose remaining budget in `source_state` so TUI/web/`/api` can show it. + +### 3.5 Tests +- Engine unit tests: budget blocks the buy at/over the cap, allows under, resets + on window rollover, position-cap override. (Pure-function style like the + `equity-engine` 23-test suite.) + +--- + +## 4. Phase 2 — AI Analyzer (b1dz → Claude/ChatGPT) — "Coinbase Advisor" analog + +b1dz calls *out* to the user's own model for regime/setup scoring, overlaid on +the deterministic signal engine. Authority = execute within budget: a strong AI +score can size up within `CRYPTO_SPEND_BUDGET_USD`, never beyond. + +### 4.1 Credential linking +- New plain toggle + secret keys via the existing settings/secret pattern: + `AI_ANALYZER_ENABLED`, `AI_PROVIDER` (`anthropic`|`openai`), + secret `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` (strict per-user). +- Optionally add `anthropic`/`openai` entries to the OAuth registry *only* if a + real OAuth-for-API flow exists; otherwise paste-key (the broker fallback path). +- Per the SPOF lesson: if no user key is set, the analyzer is simply **off** for + that user — no operator-key fallback. + +### 4.2 New package `@b1dz/ai-analyzer` +- Pure-ish module: `analyze(snapshot, candles, deterministicSignal) -> + { regime, confidence, bias, rationale }`. Provider clients are dep-free + `fetch` calls (matches the broker-client style). **Use the latest Claude model + per `claude-api` skill — do not hardcode an old model id; verify before + shipping.** +- Deterministic engine remains the gate; AI output is an *overlay* that can + raise/lower size within caps and is logged as the "reason" (PRD §2 explainability). +- Strict budget/cost control on the inference side too: cap calls/min, cache by + snapshot bucket (avoid burning the user's API quota every tick). + +### 4.3 Daemon +- New worker (or extend crypto-trade) that, when `AI_ANALYZER_ENABLED`, fetches + the per-user key (strict), runs `analyze`, and feeds the overlay into the + sizing path — still bounded by Phase 1. + +### 4.4 UI +- "AI Analyzer" settings section: provider, key, enable, max-calls/min. +- Surface the latest AI rationale/regime in dashboard + TUI. + +--- + +## 5. Phase 3 — Agent API + MCP (Claude/ChatGPT → b1dz) — "Coinbase for Agents" + +External agents place trades *into* b1dz, hard-capped by a **per-token +sub-account budget** (the "defined sub-account limits"). + +### 5.1 Scoped agent tokens (the "sub-account") +- Migration `agent_tokens(id, user_id, name, token_hash, scopes[], budget_usd, + budget_window, spent_usd_cached, allowed_actions, revoked_at, created_at, + last_used_at)` — RLS owner-only. Store only a hash of the token. +- `scopes`: `read`, `trade:crypto`, `trade:equity`. `allowed_actions` narrows + (e.g. buy-only, symbol allowlist). +- Each token = an isolated budget that draws from `crypto_spend_ledger` with + `source='agent'` and `agent_token_id` set, so a runaway agent can only spend + its own slice. + +### 5.2 Token auth +- Extend `api-auth.ts`: a new `authenticateAgent(req)` that accepts + `Authorization: Bearer b1dz_agent_...`, looks up the hash, checks revocation + + scope, returns `{ userId, tokenId, scopes, budget }`. Resolves to a + service-role client scoped to that `user_id` (agent tokens aren't Supabase + JWTs, so RLS-via-user-JWT doesn't apply — must scope explicitly + fail closed). + +### 5.3 Endpoints (thin layer over the existing engine; no new trading logic) +- `POST /api/agent/orders` — place an order; runs through the SAME Phase-1 + budget guard + risk engine, debits the token's budget, writes a ledger row. +- `GET /api/agent/portfolio`, `GET /api/agent/budget`, `GET /api/agent/quote`. +- `POST /api/agent/orders` is **idempotent** (client-supplied key) — agents retry. + +### 5.4 MCP server +- New app `apps/mcp` (or a route) exposing the above as MCP tools: + `get_portfolio`, `get_budget`, `place_order`, `get_quote`. This is the + Base-MCP analog. x402-style metered pay is a v2 follow-up — v1 = fixed + per-token budget. +- Token management UI: create/name/revoke agent tokens, set per-token budget, + view per-token spend. Show the token once on creation (hash stored). + +### 5.5 Safety +- Every agent order: scope check → idempotency → risk engine → **Phase-1 budget + guard (per-token)** → execute → ledger. Fail closed on any check. +- Global kill switch + per-token revoke. Rate-limit per token. +- Audit log of every agent action (the ledger + an `agent_actions` row). + +--- + +## 6. Sequencing & dependencies + +1. **Phase 1** (budget + ledger) — required by 2 and 3. Ships standalone. +2. **Phase 2** (AI analyzer) — depends on Phase 1 for "within budget" sizing. +3. **Phase 3** (agent API + MCP) — depends on Phase 1 for per-token budgets; + reuses the same ledger and engine guard. + +## 7. Non-goals (v1) +- No x402 metered micropayments (fixed per-token budget instead). +- No autonomous AI strategy that bypasses the deterministic engine gate. +- No operator-funded shared AI key — strictly BYO per-user. +- No custody change — orders still route to the user's own exchange/broker. + +## 8. Open questions +- AI inference cost ceiling per user (calls/min, cache TTL) — defaults TBD. +- Whether agent tokens may also drive equities (`@b1dz/equity-engine`) in v1 or + crypto-only first. +- MCP transport: hosted SSE endpoint vs. stdio shim the user runs locally. diff --git a/packages/ai-analyzer/package.json b/packages/ai-analyzer/package.json new file mode 100644 index 0000000..355ac49 --- /dev/null +++ b/packages/ai-analyzer/package.json @@ -0,0 +1,17 @@ +{ + "name": "@b1dz/ai-analyzer", + "version": "0.3.10", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { ".": "./src/index.ts" }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "lint": "eslint src", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { "@b1dz/core": "workspace:*" }, + "devDependencies": { "@types/node": "latest", "typescript": "latest", "vitest": "latest" } +} diff --git a/packages/ai-analyzer/src/index.ts b/packages/ai-analyzer/src/index.ts new file mode 100644 index 0000000..230e391 --- /dev/null +++ b/packages/ai-analyzer/src/index.ts @@ -0,0 +1,60 @@ +/** + * @b1dz/ai-analyzer — the "Coinbase Advisor" analog. b1dz calls out to the + * user's OWN model (Claude or ChatGPT) to score market regime/bias; the result + * is overlaid on the deterministic engine's sizing via `aiSizeMultiplier`, and + * the spend budget remains the hard cap. The AI never places trades by itself. + */ + +import { + callAnthropic, + callOpenAI, + DEFAULT_ANTHROPIC_MODEL, + DEFAULT_OPENAI_MODEL, + type AnalyzePromptInput, + type FetchLike, +} from './providers.js'; +import { coerceAnalysis, type AiAnalysis } from './overlay.js'; + +export type AiProvider = 'anthropic' | 'openai'; + +export interface AnalyzeOptions { + provider: AiProvider; + apiKey: string; + model?: string; + fetchImpl?: FetchLike; +} + +/** + * Run one analysis. Throws on transport/auth errors (the caller decides whether + * to disable the analyzer); returns a normalized AiAnalysis on success. + */ +export async function analyze(input: AnalyzePromptInput, opts: AnalyzeOptions): Promise { + const ts = Date.now(); + if (opts.provider === 'openai') { + const model = opts.model ?? DEFAULT_OPENAI_MODEL; + const raw = await callOpenAI(opts.apiKey, input, { model, fetchImpl: opts.fetchImpl }); + return coerceAnalysis(raw, { provider: 'openai', model, ts }); + } + const model = opts.model ?? DEFAULT_ANTHROPIC_MODEL; + const raw = await callAnthropic(opts.apiKey, input, { model, fetchImpl: opts.fetchImpl }); + return coerceAnalysis(raw, { provider: 'anthropic', model, ts }); +} + +export { + aiSizeMultiplier, + coerceAnalysis, + RateLimiter, + AI_SIZE_MIN, + AI_SIZE_MAX, + type AiAnalysis, + type Regime, + type Bias, +} from './overlay.js'; +export { + buildUserPrompt, + extractJson, + DEFAULT_ANTHROPIC_MODEL, + DEFAULT_OPENAI_MODEL, + type AnalyzePromptInput, + type FetchLike, +} from './providers.js'; diff --git a/packages/ai-analyzer/src/overlay.test.ts b/packages/ai-analyzer/src/overlay.test.ts new file mode 100644 index 0000000..93dc78d --- /dev/null +++ b/packages/ai-analyzer/src/overlay.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { + aiSizeMultiplier, + coerceAnalysis, + RateLimiter, + AI_SIZE_MIN, + AI_SIZE_MAX, + type AiAnalysis, +} from './overlay.js'; + +const NOW = 1_000_000; +const base = (over: Partial = {}): AiAnalysis => ({ + regime: 'trending', + confidence: 1, + bias: 'long', + rationale: 'x', + ts: NOW, + provider: 'anthropic', + model: 'm', + ...over, +}); + +describe('aiSizeMultiplier', () => { + it('returns 1 with no analysis', () => { + expect(aiSizeMultiplier(null, NOW)).toBe(1); + }); + + it('returns 1 when analysis is stale', () => { + expect(aiSizeMultiplier(base({ ts: NOW - 10 * 60_000 }), NOW)).toBe(1); + }); + + it('returns 1 on neutral bias', () => { + expect(aiSizeMultiplier(base({ bias: 'neutral' }), NOW)).toBe(1); + }); + + it('scales up on a confident long in a trend (capped at MAX)', () => { + expect(aiSizeMultiplier(base({ bias: 'long', confidence: 1, regime: 'trending' }), NOW)).toBe(AI_SIZE_MAX); + }); + + it('scales down on a confident short but never flips below MIN', () => { + const m = aiSizeMultiplier(base({ bias: 'short', confidence: 1, regime: 'trending' }), NOW); + expect(m).toBe(AI_SIZE_MIN); + expect(m).toBeGreaterThan(0); + }); + + it('damps magnitude in a volatile regime', () => { + const trend = aiSizeMultiplier(base({ bias: 'long', confidence: 1, regime: 'trending' }), NOW); + const vol = aiSizeMultiplier(base({ bias: 'long', confidence: 1, regime: 'volatile' }), NOW); + expect(vol).toBeLessThan(trend); + expect(vol).toBeGreaterThanOrEqual(1); + }); + + it('low confidence stays near 1', () => { + const m = aiSizeMultiplier(base({ bias: 'long', confidence: 0.05, regime: 'trending' }), NOW); + expect(m).toBeGreaterThan(1); + expect(m).toBeLessThan(1.1); + }); + + it('clamps the multiplier within [MIN, MAX]', () => { + for (const bias of ['long', 'short', 'neutral'] as const) { + for (const conf of [0, 0.5, 1, 2, -1]) { + const m = aiSizeMultiplier(base({ bias, confidence: conf }), NOW); + expect(m).toBeGreaterThanOrEqual(AI_SIZE_MIN); + expect(m).toBeLessThanOrEqual(AI_SIZE_MAX); + } + } + }); +}); + +describe('coerceAnalysis', () => { + const meta = { provider: 'anthropic' as const, model: 'm', ts: NOW }; + + it('passes through valid JSON', () => { + const a = coerceAnalysis({ regime: 'ranging', bias: 'short', confidence: 0.7, rationale: 'r' }, meta); + expect(a).toMatchObject({ regime: 'ranging', bias: 'short', confidence: 0.7, rationale: 'r' }); + }); + + it('defaults unknown/garbage fields safely', () => { + const a = coerceAnalysis({ regime: 'banana', bias: 'sideways', confidence: 'high' }, meta); + expect(a.regime).toBe('unknown'); + expect(a.bias).toBe('neutral'); + expect(a.confidence).toBe(0); + }); + + it('clamps confidence and truncates rationale', () => { + const a = coerceAnalysis({ confidence: 5, rationale: 'x'.repeat(999) }, meta); + expect(a.confidence).toBe(1); + expect(a.rationale.length).toBe(500); + }); + + it('handles null/undefined input', () => { + expect(coerceAnalysis(null, meta).bias).toBe('neutral'); + expect(coerceAnalysis(undefined, meta).regime).toBe('unknown'); + }); +}); + +describe('RateLimiter', () => { + it('allows up to maxPerMin then blocks within the window', () => { + const rl = new RateLimiter(2); + expect(rl.allow(0)).toBe(true); + expect(rl.allow(1)).toBe(true); + expect(rl.allow(2)).toBe(false); + }); + + it('refills after the window slides', () => { + const rl = new RateLimiter(1); + expect(rl.allow(0)).toBe(true); + expect(rl.allow(30_000)).toBe(false); + expect(rl.allow(61_000)).toBe(true); + }); + + it('treats maxPerMin <= 0 as unlimited', () => { + const rl = new RateLimiter(0); + for (let i = 0; i < 100; i++) expect(rl.allow(i)).toBe(true); + }); +}); diff --git a/packages/ai-analyzer/src/overlay.ts b/packages/ai-analyzer/src/overlay.ts new file mode 100644 index 0000000..ce3c682 --- /dev/null +++ b/packages/ai-analyzer/src/overlay.ts @@ -0,0 +1,86 @@ +/** + * AI overlay — pure, deterministic mapping from an AI market view onto the + * deterministic engine's order sizing. The AI never trades on its own; it can + * only scale a buy that the deterministic strategy already wants, and only + * within bounds. The spend budget is still the hard cap downstream. + */ + +export type Regime = 'trending' | 'ranging' | 'volatile' | 'unknown'; +export type Bias = 'long' | 'short' | 'neutral'; + +export interface AiAnalysis { + regime: Regime; + /** 0..1 — how confident the model is in `bias`. */ + confidence: number; + bias: Bias; + rationale: string; + /** epoch ms the analysis was produced. */ + ts: number; + provider: 'anthropic' | 'openai'; + model: string; +} + +/** Bounds on how much the AI may move sizing. Never exceeds the budget cap. */ +export const AI_SIZE_MIN = 0.25; // never below 25% of base on a confident short +export const AI_SIZE_MAX = 1.5; // never above 150% of base on a confident long + +/** + * Map an AI view onto a multiplier applied to the deterministic base buy size. + * + * - bias `long` + high confidence → up to AI_SIZE_MAX + * - bias `short` + high confidence → down to AI_SIZE_MIN (de-risk; don't flip) + * - bias `neutral` or low confidence → ~1.0 (no opinion) + * - stale analysis (older than `maxAgeMs`) → 1.0 (ignore) + * - `volatile` regime damps the magnitude (less conviction in chop) + */ +export function aiSizeMultiplier( + analysis: AiAnalysis | null, + now: number, + maxAgeMs = 5 * 60_000, +): number { + if (!analysis) return 1; + if (!Number.isFinite(analysis.ts) || now - analysis.ts > maxAgeMs) return 1; + const conf = clamp(analysis.confidence, 0, 1); + if (analysis.bias === 'neutral' || conf <= 0) return 1; + + const damp = analysis.regime === 'volatile' ? 0.5 : analysis.regime === 'ranging' ? 0.75 : 1; + if (analysis.bias === 'long') { + return clamp(1 + (AI_SIZE_MAX - 1) * conf * damp, AI_SIZE_MIN, AI_SIZE_MAX); + } + // short → shrink the long entry, never go negative/flip + return clamp(1 - (1 - AI_SIZE_MIN) * conf * damp, AI_SIZE_MIN, 1); +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +/** Coerce arbitrary model JSON into a valid AiAnalysis (defensive parsing). */ +export function coerceAnalysis( + raw: unknown, + meta: { provider: 'anthropic' | 'openai'; model: string; ts: number }, +): AiAnalysis { + const o = (raw ?? {}) as Record; + const regime: Regime = ['trending', 'ranging', 'volatile'].includes(String(o.regime)) + ? (o.regime as Regime) + : 'unknown'; + const bias: Bias = ['long', 'short', 'neutral'].includes(String(o.bias)) ? (o.bias as Bias) : 'neutral'; + const confNum = Number(o.confidence); + const confidence = Number.isFinite(confNum) ? clamp(confNum, 0, 1) : 0; + const rationale = typeof o.rationale === 'string' ? o.rationale.slice(0, 500) : ''; + return { regime, confidence, bias, rationale, ts: meta.ts, provider: meta.provider, model: meta.model }; +} + +/** Simple token-bucket rate limiter to bound a user's inference spend. */ +export class RateLimiter { + private hits: number[] = []; + constructor(private maxPerMin: number) {} + /** Returns true if a call is allowed now (and records it). */ + allow(now = Date.now()): boolean { + const cutoff = now - 60_000; + this.hits = this.hits.filter((t) => t > cutoff); + if (this.maxPerMin > 0 && this.hits.length >= this.maxPerMin) return false; + this.hits.push(now); + return true; + } +} diff --git a/packages/ai-analyzer/src/providers.test.ts b/packages/ai-analyzer/src/providers.test.ts new file mode 100644 index 0000000..871e48b --- /dev/null +++ b/packages/ai-analyzer/src/providers.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from 'vitest'; +import { analyze } from './index.js'; +import { buildUserPrompt, extractJson, type FetchLike } from './providers.js'; + +const input = { + pair: 'BTC-USD', + exchange: 'kraken', + lastPrice: 65000, + closes: [64000, 64500, 65000], + deterministicSignal: { side: 'buy' as const, strength: 0.8, reason: 'trend up' }, +}; + +const fakeFetch = (payload: unknown, ok = true, status = 200): FetchLike => + vi.fn(async () => ({ ok, status, text: async () => JSON.stringify(payload) })); + +describe('extractJson', () => { + it('pulls a JSON object out of surrounding prose', () => { + expect(extractJson('here you go: {"a":1} cheers')).toEqual({ a: 1 }); + }); + it('returns {} on garbage', () => { + expect(extractJson('no json here')).toEqual({}); + expect(extractJson('{broken')).toEqual({}); + }); +}); + +describe('buildUserPrompt', () => { + it('includes pair, price, closes and the deterministic signal', () => { + const p = buildUserPrompt(input); + expect(p).toContain('BTC-USD'); + expect(p).toContain('65000'); + expect(p).toContain('buy'); + }); +}); + +describe('analyze (anthropic)', () => { + it('parses a Messages API response into a normalized analysis', async () => { + const fetchImpl = fakeFetch({ + content: [{ type: 'text', text: '{"regime":"trending","bias":"long","confidence":0.9,"rationale":"uptrend"}' }], + }); + const a = await analyze(input, { provider: 'anthropic', apiKey: 'sk-ant-x', fetchImpl }); + expect(a.provider).toBe('anthropic'); + expect(a.bias).toBe('long'); + expect(a.confidence).toBe(0.9); + expect(a.regime).toBe('trending'); + }); + + it('throws on a non-200 response', async () => { + const fetchImpl = fakeFetch({ error: 'bad key' }, false, 401); + await expect(analyze(input, { provider: 'anthropic', apiKey: 'bad', fetchImpl })).rejects.toThrow(/anthropic http 401/); + }); +}); + +describe('analyze (openai)', () => { + it('parses a chat-completions response', async () => { + const fetchImpl = fakeFetch({ + choices: [{ message: { content: '{"regime":"ranging","bias":"neutral","confidence":0.2,"rationale":"chop"}' } }], + }); + const a = await analyze(input, { provider: 'openai', apiKey: 'sk-x', fetchImpl }); + expect(a.provider).toBe('openai'); + expect(a.bias).toBe('neutral'); + expect(a.regime).toBe('ranging'); + }); +}); diff --git a/packages/ai-analyzer/src/providers.ts b/packages/ai-analyzer/src/providers.ts new file mode 100644 index 0000000..61d39f0 --- /dev/null +++ b/packages/ai-analyzer/src/providers.ts @@ -0,0 +1,119 @@ +/** + * Dependency-free provider clients (Anthropic + OpenAI), matching the + * dep-free `fetch` style of the broker clients. Each takes the user's OWN + * API key and returns raw model JSON, which `analyze()` coerces. + * + * Model ids are configurable; the defaults are the latest cost-efficient + * models for high-frequency scoring (the user pays for their own inference). + * If you change a default, verify the id against the provider's current + * catalogue first. + */ + +export const DEFAULT_ANTHROPIC_MODEL = 'claude-haiku-4-5-20251001'; +export const DEFAULT_OPENAI_MODEL = 'gpt-4o-mini'; + +export interface AnalyzePromptInput { + pair: string; + exchange: string; + lastPrice: number; + /** Recent closes oldest→newest (compact context for the model). */ + closes: number[]; + /** The deterministic engine's current signal, for the model to weigh in on. */ + deterministicSignal?: { side: 'buy' | 'sell'; strength: number; reason: string } | null; +} + +const SYSTEM_PROMPT = + 'You are a short-term crypto market analyst. Given recent price context and a ' + + 'deterministic strategy signal, classify the market regime and your directional ' + + 'bias. Respond with ONLY a compact JSON object: ' + + '{"regime":"trending|ranging|volatile","bias":"long|short|neutral","confidence":0..1,"rationale":"<=1 sentence"}. ' + + 'Be conservative: prefer neutral / low confidence in choppy or unclear conditions.'; + +export function buildUserPrompt(input: AnalyzePromptInput): string { + const sig = input.deterministicSignal + ? `${input.deterministicSignal.side} (strength ${input.deterministicSignal.strength.toFixed(2)}): ${input.deterministicSignal.reason}` + : 'none'; + return [ + `Pair: ${input.pair} on ${input.exchange}`, + `Last price: ${input.lastPrice}`, + `Recent closes (old→new): ${input.closes.map((c) => Number(c.toFixed(6))).join(', ')}`, + `Deterministic signal: ${sig}`, + ].join('\n'); +} + +/** Minimal fetch type so this package needs no DOM/node-fetch types. */ +type FetchLike = (url: string, init: { + method: string; + headers: Record; + body: string; +}) => Promise<{ ok: boolean; status: number; text: () => Promise }>; + +const getFetch = (f?: FetchLike): FetchLike => f ?? (globalThis.fetch as unknown as FetchLike); + +/** Extract the first JSON object from a model text response. */ +export function extractJson(text: string): unknown { + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start === -1 || end === -1 || end < start) return {}; + try { + return JSON.parse(text.slice(start, end + 1)); + } catch { + return {}; + } +} + +export async function callAnthropic( + apiKey: string, + input: AnalyzePromptInput, + opts: { model?: string; fetchImpl?: FetchLike } = {}, +): Promise { + const fetchImpl = getFetch(opts.fetchImpl); + const res = await fetchImpl('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: opts.model ?? DEFAULT_ANTHROPIC_MODEL, + max_tokens: 256, + system: SYSTEM_PROMPT, + messages: [{ role: 'user', content: buildUserPrompt(input) }], + }), + }); + if (!res.ok) throw new Error(`anthropic http ${res.status}: ${(await res.text()).slice(0, 200)}`); + const body = JSON.parse(await res.text()) as { content?: Array<{ type: string; text?: string }> }; + const text = (body.content ?? []).filter((b) => b.type === 'text').map((b) => b.text ?? '').join(''); + return extractJson(text); +} + +export async function callOpenAI( + apiKey: string, + input: AnalyzePromptInput, + opts: { model?: string; fetchImpl?: FetchLike } = {}, +): Promise { + const fetchImpl = getFetch(opts.fetchImpl); + const res = await fetchImpl('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: opts.model ?? DEFAULT_OPENAI_MODEL, + max_tokens: 256, + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: SYSTEM_PROMPT }, + { role: 'user', content: buildUserPrompt(input) }, + ], + }), + }); + if (!res.ok) throw new Error(`openai http ${res.status}: ${(await res.text()).slice(0, 200)}`); + const body = JSON.parse(await res.text()) as { choices?: Array<{ message?: { content?: string } }> }; + const text = body.choices?.[0]?.message?.content ?? '{}'; + return extractJson(text); +} + +export type { FetchLike }; diff --git a/packages/ai-analyzer/tsconfig.build.json b/packages/ai-analyzer/tsconfig.build.json new file mode 100644 index 0000000..463f57b --- /dev/null +++ b/packages/ai-analyzer/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "noEmit": false }, + "exclude": ["**/*.test.ts"] +} diff --git a/packages/ai-analyzer/tsconfig.json b/packages/ai-analyzer/tsconfig.json new file mode 100644 index 0000000..bf5a36d --- /dev/null +++ b/packages/ai-analyzer/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"] +} diff --git a/packages/core/src/agent-tokens.test.ts b/packages/core/src/agent-tokens.test.ts new file mode 100644 index 0000000..42af1e9 --- /dev/null +++ b/packages/core/src/agent-tokens.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { + isAgentToken, + isValidScope, + sanitizeScopes, + tokenHasScope, + isTokenActive, + symbolAllowed, + checkAgentBudget, + agentWindowStart, + AGENT_TOKEN_PREFIX, + type AgentTokenRow, +} from './agent-tokens.js'; + +const row = (over: Partial = {}): AgentTokenRow => ({ + id: 't1', + user_id: 'u1', + name: 'bot', + token_hash: 'h', + token_suffix: 'abcd', + scopes: ['read', 'trade:crypto'], + budget_usd: 100, + budget_window: 'daily', + allowed_symbols: null, + revoked_at: null, + last_used_at: null, + created_at: '2026-06-18T00:00:00Z', + ...over, +}); + +describe('token shape helpers', () => { + it('recognizes the agent token prefix', () => { + expect(isAgentToken(`${AGENT_TOKEN_PREFIX}xyz`)).toBe(true); + expect(isAgentToken('eyJ...jwt')).toBe(false); + expect(isAgentToken(null)).toBe(false); + }); + + it('validates scopes', () => { + expect(isValidScope('trade:crypto')).toBe(true); + expect(isValidScope('trade:everything')).toBe(false); + }); + + it('sanitizes scopes, defaulting to read', () => { + expect(sanitizeScopes(['read', 'bogus', 'trade:crypto', 'read'])).toEqual(['read', 'trade:crypto']); + expect(sanitizeScopes([])).toEqual(['read']); + expect(sanitizeScopes('nope')).toEqual(['read']); + }); +}); + +describe('scope + state checks', () => { + it('checks scope membership', () => { + expect(tokenHasScope(row(), 'trade:crypto')).toBe(true); + expect(tokenHasScope(row(), 'trade:equity')).toBe(false); + }); + + it('treats revoked tokens as inactive', () => { + expect(isTokenActive(row())).toBe(true); + expect(isTokenActive(row({ revoked_at: '2026-06-18T01:00:00Z' }))).toBe(false); + }); + + it('enforces the symbol allowlist (case-insensitive; empty = any)', () => { + expect(symbolAllowed(row({ allowed_symbols: null }), 'BTC-USD')).toBe(true); + expect(symbolAllowed(row({ allowed_symbols: [] }), 'BTC-USD')).toBe(true); + expect(symbolAllowed(row({ allowed_symbols: ['btc-usd'] }), 'BTC-USD')).toBe(true); + expect(symbolAllowed(row({ allowed_symbols: ['ETH-USD'] }), 'BTC-USD')).toBe(false); + }); +}); + +describe('checkAgentBudget', () => { + it('allows an order within the remaining token budget', () => { + const r = checkAgentBudget(row({ budget_usd: 100 }), 40, 50); + expect(r.allowed).toBe(true); + expect(r.remainingUsd).toBe(60); + }); + + it('rejects an order over the remaining budget', () => { + const r = checkAgentBudget(row({ budget_usd: 100 }), 80, 50); + expect(r.allowed).toBe(false); + expect(r.remainingUsd).toBe(20); + }); + + it('rejects any trade when budget is 0', () => { + const r = checkAgentBudget(row({ budget_usd: 0 }), 0, 1); + expect(r.allowed).toBe(false); + expect(r.reason).toContain('no trading budget'); + }); + + it('handles string budgets from the DB', () => { + const r = checkAgentBudget(row({ budget_usd: '100' }), 0, 50); + expect(r.allowed).toBe(true); + expect(r.budgetUsd).toBe(100); + }); +}); + +describe('agentWindowStart', () => { + const tue = Date.UTC(2026, 5, 16, 9, 0, 0); + it('daily/weekly/monthly align with the engine windows', () => { + expect(agentWindowStart('daily', tue)).toBe(Date.UTC(2026, 5, 16)); + expect(agentWindowStart('weekly', tue)).toBe(Date.UTC(2026, 5, 15)); // Monday + expect(agentWindowStart('monthly', tue)).toBe(Date.UTC(2026, 5, 1)); + }); +}); diff --git a/packages/core/src/agent-tokens.ts b/packages/core/src/agent-tokens.ts new file mode 100644 index 0000000..ffec154 --- /dev/null +++ b/packages/core/src/agent-tokens.ts @@ -0,0 +1,101 @@ +/** + * Agent token logic — the "Coinbase for Agents" sub-account model. Pure, + * dependency-free helpers (scope checks, symbol allowlist, per-token budget + * arithmetic) shared by the web API and the daemon. Token generation/hashing + * (which needs node:crypto) lives in the web layer; this module is the + * unit-testable policy core. + */ + +export type AgentScope = 'read' | 'trade:crypto' | 'trade:equity'; +export const AGENT_SCOPES: readonly AgentScope[] = ['read', 'trade:crypto', 'trade:equity']; + +export type AgentBudgetWindow = 'daily' | 'weekly' | 'monthly'; + +export interface AgentTokenRow { + id: string; + user_id: string; + name: string; + token_hash: string; + token_suffix: string; + scopes: string[]; + budget_usd: number | string; + budget_window: string; + allowed_symbols: string[] | null; + revoked_at: string | null; + last_used_at: string | null; + created_at: string; +} + +export const AGENT_TOKEN_PREFIX = 'b1dz_agent_'; + +export function isAgentToken(value: string | null | undefined): boolean { + return typeof value === 'string' && value.startsWith(AGENT_TOKEN_PREFIX); +} + +export function isValidScope(s: string): s is AgentScope { + return (AGENT_SCOPES as readonly string[]).includes(s); +} + +/** Filter arbitrary input down to the known, valid scopes (deduped). */ +export function sanitizeScopes(input: unknown): AgentScope[] { + const arr = Array.isArray(input) ? input : []; + const out = new Set(); + for (const s of arr) if (typeof s === 'string' && isValidScope(s)) out.add(s); + if (out.size === 0) out.add('read'); + return [...out]; +} + +export function tokenHasScope(row: Pick, scope: AgentScope): boolean { + return Array.isArray(row.scopes) && row.scopes.includes(scope); +} + +export function isTokenActive(row: Pick): boolean { + return !row.revoked_at; +} + +/** True when the token may trade this symbol (empty/null allowlist = any). */ +export function symbolAllowed(row: Pick, symbol: string): boolean { + const list = row.allowed_symbols; + if (!list || list.length === 0) return true; + return list.map((s) => s.toUpperCase()).includes(symbol.toUpperCase()); +} + +export interface AgentBudgetCheck { + allowed: boolean; + budgetUsd: number; + spentUsd: number; + remainingUsd: number; + reason: string; +} + +/** + * Per-token budget gate. `spentThisWindowUsd` is summed by the caller from + * crypto_spend_ledger filtered by agent_token_id over the window. A budget of + * 0 means the token cannot trade at all (read-only by budget). + */ +export function checkAgentBudget( + row: Pick, + spentThisWindowUsd: number, + orderUsd: number, +): AgentBudgetCheck { + const budgetUsd = Number(row.budget_usd) || 0; + const spentUsd = Math.max(0, spentThisWindowUsd); + const remainingUsd = Math.max(0, budgetUsd - spentUsd); + if (budgetUsd <= 0) { + return { allowed: false, budgetUsd, spentUsd, remainingUsd: 0, reason: 'token has no trading budget' }; + } + if (orderUsd > remainingUsd + 1e-9) { + return { allowed: false, budgetUsd, spentUsd, remainingUsd, reason: `order $${orderUsd.toFixed(2)} exceeds remaining token budget $${remainingUsd.toFixed(2)}` }; + } + return { allowed: true, budgetUsd, spentUsd, remainingUsd, reason: 'ok' }; +} + +/** Start-of-window epoch ms in UTC (matches the engine's spend-budget windows). */ +export function agentWindowStart(window: AgentBudgetWindow, now: number): number { + const d = new Date(now); + if (window === 'monthly') return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); + const dayStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + if (window === 'daily') return dayStart; + const dow = new Date(dayStart).getUTCDay(); + return dayStart - ((dow + 6) % 7) * 86_400_000; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 62e546d..7f43c4e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,3 +14,4 @@ export * from './runtime-cache.js'; export * from './plugins.js'; export * from './plugin-catalog.js'; export * from './settings-fields.js'; +export * from './agent-tokens.js'; diff --git a/packages/core/src/settings-fields.ts b/packages/core/src/settings-fields.ts index d4ea614..735a5f7 100644 --- a/packages/core/src/settings-fields.ts +++ b/packages/core/src/settings-fields.ts @@ -45,6 +45,9 @@ export const SECRET_FIELDS = [ // Proxy creds 'PROXY_USERNAME', 'PROXY_PASSWORD', + // AI analyzer — per-user BYO inference keys (strict; never operator env) + 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', ] as const; export type SecretField = typeof SECRET_FIELDS[number]; @@ -81,6 +84,8 @@ export const PLAIN_STRING_FIELDS = [ 'WEBULL_ACCOUNT_ID', 'WEBULL_BASE_URL', 'EQUITY_WATCHLIST', + // AI analyzer + 'AI_PROVIDER', ] as const; export const PLAIN_NUMBER_FIELDS = [ @@ -165,6 +170,8 @@ export const PLAIN_NUMBER_FIELDS = [ 'EQUITY_MIN_SIGNAL', 'EQUITY_ACCOUNT_EQUITY_USD', 'EQUITY_CLOSE_BUFFER_MIN', + // AI analyzer + 'AI_MAX_CALLS_PER_MIN', ] as const; export const PLAIN_BOOL_FIELDS = [ @@ -188,6 +195,8 @@ export const PLAIN_BOOL_FIELDS = [ 'TRADIER_SANDBOX', 'TRADESTATION_SIM', 'WEBULL_PAPER', + // AI analyzer + 'AI_ANALYZER_ENABLED', ] as const; export type PlainStringField = typeof PLAIN_STRING_FIELDS[number]; diff --git a/packages/source-crypto-trade/src/index.ts b/packages/source-crypto-trade/src/index.ts index dcb3703..d9e183f 100644 --- a/packages/source-crypto-trade/src/index.ts +++ b/packages/source-crypto-trade/src/index.ts @@ -63,7 +63,23 @@ import { hardStopPctFromEnv, } from './trade-config.js'; import { decideExit } from './exit-decision.js'; +import { + type BudgetWindow, + type SpendBudgetState, + checkSpendBudget, + recordSpend as recordSpendState, + freshBudgetState, +} from './spend-budget.js'; +export { + type BudgetWindow, + type SpendBudgetState, + type BudgetDecision, + checkSpendBudget, + windowStartFor, + isBudgetWindow, + BUDGET_WINDOWS, +} from './spend-budget.js'; export { dcaConfigFromEnv, perExchangeAllocationPct, DCA_DEFAULTS, type DcaConfig } from './dca-config.js'; export { decideDcaBuys, type DcaBuy, type DcaPlannerInput } from './dca-planner.js'; export { runDcaBacktest, type DcaBacktestInput, type DcaBacktestResult, type DcaBacktestPosition } from './dca-backtest.js'; @@ -1763,6 +1779,253 @@ function isDailyLossLimitHit(): boolean { return dailyPnlPct() <= -dailyLossLimitPctRuntime; } +/** + * Crypto spend budget — the rolling USD cap on BUYS (engine + AI + agent), + * the single safety primitive enforced before every entry. `null` = no cap. + * Set per-user from the daemon via `crypto-ui-settings` (NOT env), same channel + * as the daily loss limit. The live spend ledger persists in the DB; this + * in-memory state is the fast path and is reconciled from the daemon each tick. + */ +let spendBudgetUsd: number | null = null; +let spendBudgetWindow: BudgetWindow = 'daily'; +let spendBudgetState: SpendBudgetState = freshBudgetState('daily', Date.now()); +/** Per-user override of the per-position cap. null → MAX_POSITION_USD default. */ +let maxPositionUsdRuntime: number | null = null; +let lastBudgetBlockLogAt = 0; + +export function setSpendBudget(usd: number | null, window: BudgetWindow = 'daily'): void { + const next = usd != null && Number.isFinite(usd) && usd > 0 ? usd : null; + if (window !== spendBudgetWindow) { + spendBudgetWindow = window; + spendBudgetState = freshBudgetState(window, Date.now()); + } + if (next !== spendBudgetUsd) { + spendBudgetUsd = next; + console.log(`[trade] crypto spend budget → ${next == null ? 'unlimited' : `$${next.toFixed(2)}/${window}`}`); + } +} + +/** + * Reconcile the in-memory spent counter from the authoritative DB ledger. + * The daemon passes the summed spend for the current window so the budget + * survives daemon restarts (the in-memory counter alone would reset to 0). + */ +export function syncSpendLedger(spentUsd: number): void { + if (Number.isFinite(spentUsd) && spentUsd >= 0) { + spendBudgetState = { spentUsd, windowStart: spendBudgetState.windowStart }; + } +} + +export function setMaxPositionUsd(usd: number | null): void { + const next = usd != null && Number.isFinite(usd) && usd > 0 ? usd : null; + if (next !== maxPositionUsdRuntime) { + maxPositionUsdRuntime = next; + console.log(`[trade] crypto max position → ${next == null ? `$${MAX_POSITION_USD} (default)` : `$${next.toFixed(2)}`}`); + } +} + +/** Effective per-position cap: per-user override, else the package default. */ +function effectiveMaxPositionUsd(): number { + return maxPositionUsdRuntime != null && maxPositionUsdRuntime > 0 + ? maxPositionUsdRuntime + : MAX_POSITION_USD; +} + +/** + * AI analyzer size overlay — a multiplier on the base buy size, set by the + * daemon's AI worker from the user's own model. Clamped [0.25, 1.5]; the spend + * budget is still the hard cap downstream, so this can only resize a buy the + * deterministic strategy already wants, never create or uncap one. + */ +let aiSizeMultiplierRuntime = 1; +export function setAiSizeMultiplier(mult: number | null): void { + const next = mult != null && Number.isFinite(mult) ? Math.max(0.25, Math.min(1.5, mult)) : 1; + if (next !== aiSizeMultiplierRuntime) { + aiSizeMultiplierRuntime = next; + console.log(`[trade] AI size multiplier → ${next.toFixed(2)}×`); + } +} +export function getAiSizeMultiplier(): number { + return aiSizeMultiplierRuntime; +} + +/** Per-position cap scaled by the AI overlay (still inside Math.min(available,…) + * at every call site, so it can never exceed the wallet/quote balance). */ +function aiAdjustedMaxPositionUsd(): number { + return effectiveMaxPositionUsd() * aiSizeMultiplierRuntime; +} + +export function getSpendBudgetStatus(): { + budgetUsd: number | null; + window: BudgetWindow; + spentUsd: number; + remainingUsd: number | null; + maxPositionUsd: number; +} { + const remaining = spendBudgetUsd == null ? null : Math.max(0, spendBudgetUsd - spendBudgetState.spentUsd); + return { + budgetUsd: spendBudgetUsd, + window: spendBudgetWindow, + spentUsd: spendBudgetState.spentUsd, + remainingUsd: remaining, + maxPositionUsd: effectiveMaxPositionUsd(), + }; +} + +/** + * Clamp a desired buy notional to what the spend budget allows. + * Returns the amount that may actually be spent (0 = blocked). Logs a + * throttled line when a buy is blocked or clamped so operators see why. + */ +function applySpendBudget(orderUsd: number, label: string): number { + const decision = checkSpendBudget({ + budgetUsd: spendBudgetUsd, + state: spendBudgetState, + orderUsd, + window: spendBudgetWindow, + now: Date.now(), + minOrderUsd: 1, + }); + spendBudgetState = decision.state; // persist rollover + if (!decision.allowed || decision.allowedUsd < orderUsd) { + const now = Date.now(); + if (now - lastBudgetBlockLogAt >= 60_000) { + lastBudgetBlockLogAt = now; + console.log(`[trade] ${label} ${decision.reason}`); + } + } + return decision.allowed ? decision.allowedUsd : 0; +} + +/** A confirmed BUY, buffered for the daemon to persist to the spend ledger. */ +export interface SpendLedgerEntry { + ts: number; + usd: number; + exchange: string; + pair: string; + source: 'engine' | 'ai' | 'agent'; + /** set when an agent token initiated the buy (for per-token budget sums). */ + tokenId?: string | null; +} +let spendLedgerBuffer: SpendLedgerEntry[] = []; + +/** Record a confirmed BUY against the in-memory window state + ledger buffer. */ +function recordSpend(usd: number, meta?: { exchange?: string; pair?: string; source?: SpendLedgerEntry['source']; tokenId?: string | null }): void { + if (!Number.isFinite(usd) || usd <= 0) return; + spendBudgetState = recordSpendState(spendBudgetState, usd, spendBudgetWindow, Date.now()); + spendLedgerBuffer.push({ + ts: Date.now(), + usd, + exchange: meta?.exchange ?? 'unknown', + pair: meta?.pair ?? 'unknown', + source: meta?.source ?? 'engine', + tokenId: meta?.tokenId ?? null, + }); +} + +/** Drain buffered spend-ledger entries (the daemon persists these to the DB). */ +export function drainSpendLedger(): SpendLedgerEntry[] { + const out = spendLedgerBuffer; + spendLedgerBuffer = []; + return out; +} + +export interface AgentBuyArgs { + pair: string; // e.g. BTC-USD + exchange?: string; // optional venue hint; else first CEX with a live price + usd: number; + tokenId: string; +} +export interface AgentBuyResult { + ok: boolean; + message: string; + pair: string; + exchange?: string; + filledUsd?: number; + price?: number; +} + +const AGENT_BUY_VENUES = ['kraken', 'coinbase', 'binance-us', 'gemini'] as const; + +/** + * Execute an agent-initiated market BUY (the "Coinbase for Agents" fill path). + * IOC limit at ask+slippage on a CEX with a live price, hard-capped by the + * spend budget, honoring the trading-halt override. Records the spend tagged + * `source='agent'` + token id so per-token budgets reconcile, and registers the + * position so the normal exit rules manage it. Reuses the same order clients + + * position tracking as the strategy path — no separate execution semantics. + */ +export async function executeAgentMarketBuy(args: AgentBuyArgs): Promise { + const pair = args.pair.toUpperCase(); + if (tradingOverride === false) { + return { ok: false, message: 'trading disabled by user override', pair }; + } + const venues = args.exchange ? [args.exchange] : [...AGENT_BUY_VENUES]; + let exchange: string | null = null; + let snap: MarketSnapshot | null = null; + for (const v of venues) { + const s = latestSnapshotFor(v, pair); + if (s && s.ask > 0) { exchange = v; snap = s; break; } + } + if (!exchange || !snap) { + return { ok: false, message: `no live price for ${pair} on ${args.exchange ?? 'any venue'}`, pair }; + } + + const allowed = applySpendBudget(args.usd, `AGENT-BUY ${exchange}:${pair} blocked —`); + if (allowed <= 0) { + return { ok: false, message: 'crypto spend budget reached', pair, exchange }; + } + + const feeRate = feeRateForExchange(exchange); + const price = snap.ask; + const slippageBps = buySlippageBpsFromEnv(); + const aggressivePrice = price * (1 + slippageBps / 10_000); + const volumeAtAggressive = allowed / aggressivePrice; + + console.log(`[trade] ATTEMPT AGENT BUY ${exchange}:${pair} vol=${volumeAtAggressive.toFixed(8)} @ $${aggressivePrice.toFixed(6)} spend=$${allowed.toFixed(2)} (token ${args.tokenId})`); + try { + let txInfo = ''; + if (exchange === 'kraken') { + const krakenPair = pair.replace('-', '').replace('BTC', 'XBT'); + const result = await placeKrakenOrder({ pair: krakenPair, type: 'buy', ordertype: 'limit', volume: volumeAtAggressive.toFixed(8), price: aggressivePrice.toFixed(2), timeinforce: 'IOC' }); + txInfo = `${result.descr.order} txid=${result.txid}`; + } else if (exchange === 'coinbase') { + const result = await placeCoinbaseOrder({ productId: pair, side: 'BUY', size: volumeAtAggressive.toFixed(8), limitPrice: String(aggressivePrice), ioc: true }); + txInfo = `orderId=${result.order_id}`; + } else if (exchange === 'binance-us') { + const result = await placeBinanceOrder({ symbol: pair.replace('-', ''), side: 'BUY', type: 'LIMIT', quantity: volumeAtAggressive.toFixed(8), price: aggressivePrice.toFixed(2), timeInForce: 'IOC' }); + txInfo = `orderId=${result.orderId}`; + } else if (exchange === 'gemini') { + const result = await placeGeminiOrder({ symbol: pair.replace('-', '').toLowerCase(), side: 'buy', amount: volumeAtAggressive.toFixed(8), price: aggressivePrice.toFixed(2), options: ['immediate-or-cancel'] }); + txInfo = `order_id=${result.order_id} executed=${result.executed_amount}`; + } else { + return { ok: false, message: `unsupported venue ${exchange}`, pair, exchange }; + } + + const posKey = `${exchange}:${pair}`; + const entryFeeUsd = aggressivePrice * volumeAtAggressive * feeRate; + openPositions.set(posKey, { + pair, + exchange, + entryPrice: aggressivePrice, + volume: volumeAtAggressive, + entryTime: Date.now(), + highWaterMark: aggressivePrice, + strategyId: 'agent', + entryFee: entryFeeUsd, + priceSamples: [aggressivePrice], + }); + recordDailyFee(entryFeeUsd); + recordSpend(aggressivePrice * volumeAtAggressive, { exchange, pair, source: 'agent', tokenId: args.tokenId }); + console.log(`[trade] AGENT BUY placed ${exchange}:${pair} ${txInfo}`); + return { ok: true, message: `bought ~${volumeAtAggressive.toFixed(8)} ${pair} on ${exchange}`, pair, exchange, filledUsd: aggressivePrice * volumeAtAggressive, price: aggressivePrice }; + } catch (e) { + const msg = (e as Error).message; + console.error(`[trade] AGENT BUY FAILED ${exchange}:${pair}: ${msg}`); + return { ok: false, message: msg, pair, exchange }; + } +} + /** * Manual trading override set by the user via TUI. * null → default behaviour (respect daily loss limit) @@ -2080,7 +2343,13 @@ async function actOnDex(args: { if (availableQuote < MIN_SPENDABLE_QUOTE_USD) { return { ok: false, message: `insufficient USDC on ${exchange} ($${availableQuote.toFixed(2)})` }; } - const spendBudget = Math.min(availableQuote, MAX_POSITION_USD) * 0.98; + const spendBudget = applySpendBudget( + Math.min(availableQuote, aiAdjustedMaxPositionUsd()) * 0.98, + `DEX-BUY ${exchange}:${pair} blocked —`, + ); + if (spendBudget <= 0) { + return { ok: false, message: `crypto spend budget reached on ${exchange}` }; + } if (spendBudget < MIN_SPENDABLE_QUOTE_USD) { return { ok: false, message: `spend budget too small on ${exchange} ($${spendBudget.toFixed(2)})` }; } @@ -2105,6 +2374,7 @@ async function actOnDex(args: { } const entryPrice = result.fillPrice ?? price; const baseVolume = result.baseVolume ?? estVolume; + recordSpend(entryPrice * baseVolume, { exchange, pair, source: 'engine' }); // count this buy against the spend budget const posKey = `${exchange}:${pair}`; openPositions.set(posKey, { pair, @@ -2454,7 +2724,13 @@ export function makeCryptoTradeSource(strategy?: Strategy): Source { spendableQuote = spendableQuoteFor(tradeExchange, item.pair); } if (spendableQuote < MIN_SPENDABLE_QUOTE_USD) return null; - const spendBudget = Math.min(spendableQuote, MAX_POSITION_USD) * 0.98; + // Spend budget gate — don't even emit a buy signal once the rolling + // crypto spend budget is exhausted (act() re-checks authoritatively). + const spendBudget = applySpendBudget( + Math.min(spendableQuote, aiAdjustedMaxPositionUsd()) * 0.98, + `BUY ${tradeExchange}:${item.pair} blocked —`, + ); + if (spendBudget <= 0) return null; const minExecutableUsd = minExecutableUsdByMarket.get(`${tradeExchange}:${item.pair}`) ?? 0; if (minExecutableUsd > 0 && spendBudget < minExecutableUsd) return null; @@ -2660,7 +2936,13 @@ export function makeCryptoTradeSource(strategy?: Strategy): Source { return { ok: false, message: `insufficient ${quoteAsset} on ${exchange} ($${availableQuote.toFixed(2)})` }; } const feeRate = feeRateForExchange(exchange); - const spendBudget = Math.min(availableQuote, MAX_POSITION_USD) * Math.max(0.9, 1 - feeRate - 0.02); + const spendBudget = applySpendBudget( + Math.min(availableQuote, aiAdjustedMaxPositionUsd()) * Math.max(0.9, 1 - feeRate - 0.02), + `BUY ${exchange}:${pair} blocked —`, + ); + if (spendBudget <= 0) { + return { ok: false, message: `crypto spend budget reached on ${exchange}` }; + } if (spendBudget < 5) { return { ok: false, message: `insufficient ${quoteAsset} on ${exchange} ($${availableQuote.toFixed(2)})` }; } @@ -2765,6 +3047,7 @@ export function makeCryptoTradeSource(strategy?: Strategy): Source { priceSamples: [aggressivePrice], }); recordDailyFee(entryFeeUsd); + recordSpend(aggressivePrice * volumeAtAggressive, { exchange, pair, source: 'engine' }); // count buy against spend budget console.log(`[trade] BUY placed ${exchange}: ${txInfo}`); return { ok: true, message: `bought up to ${volumeAtAggressive.toFixed(8)} on ${exchange} @ ≤$${aggressivePrice.toFixed(6)}` }; } catch (e) { diff --git a/packages/source-crypto-trade/src/spend-budget.test.ts b/packages/source-crypto-trade/src/spend-budget.test.ts new file mode 100644 index 0000000..f4e181a --- /dev/null +++ b/packages/source-crypto-trade/src/spend-budget.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { + windowStartFor, + rolloverIfNeeded, + checkSpendBudget, + recordSpend, + freshBudgetState, + isBudgetWindow, + type SpendBudgetState, +} from './spend-budget.js'; + +const MON = Date.UTC(2026, 5, 15, 12, 0, 0); // Mon 2026-06-15 12:00 UTC +const TUE = Date.UTC(2026, 5, 16, 9, 0, 0); // Tue 2026-06-16 +const NEXT_MON = Date.UTC(2026, 5, 22, 1, 0, 0); // Mon 2026-06-22 +const NEXT_MONTH = Date.UTC(2026, 6, 2, 1, 0, 0); // Thu 2026-07-02 + +describe('windowStartFor', () => { + it('daily → midnight UTC', () => { + expect(windowStartFor('daily', MON)).toBe(Date.UTC(2026, 5, 15)); + }); + it('weekly → most recent Monday', () => { + expect(windowStartFor('weekly', TUE)).toBe(Date.UTC(2026, 5, 15)); + expect(windowStartFor('weekly', MON)).toBe(Date.UTC(2026, 5, 15)); + }); + it('monthly → 1st of month', () => { + expect(windowStartFor('monthly', MON)).toBe(Date.UTC(2026, 5, 1)); + }); +}); + +describe('isBudgetWindow', () => { + it('validates', () => { + expect(isBudgetWindow('daily')).toBe(true); + expect(isBudgetWindow('weekly')).toBe(true); + expect(isBudgetWindow('hourly')).toBe(false); + expect(isBudgetWindow(5)).toBe(false); + }); +}); + +describe('checkSpendBudget', () => { + const state = (over: Partial = {}): SpendBudgetState => ({ + spentUsd: 0, + windowStart: windowStartFor('daily', MON), + ...over, + }); + + it('allows when no budget is configured (unlimited)', () => { + const d = checkSpendBudget({ budgetUsd: null, state: state(), orderUsd: 100, window: 'daily', now: MON }); + expect(d.allowed).toBe(true); + expect(d.remainingUsd).toBe(Infinity); + expect(d.allowedUsd).toBe(100); + }); + + it('allows a buy under the budget', () => { + const d = checkSpendBudget({ budgetUsd: 500, state: state({ spentUsd: 100 }), orderUsd: 50, window: 'daily', now: MON }); + expect(d.allowed).toBe(true); + expect(d.remainingUsd).toBe(400); + expect(d.allowedUsd).toBe(50); + }); + + it('clamps a buy that partially exceeds the remaining budget', () => { + const d = checkSpendBudget({ budgetUsd: 500, state: state({ spentUsd: 480 }), orderUsd: 50, window: 'daily', now: MON }); + expect(d.allowed).toBe(true); + expect(d.allowedUsd).toBe(20); + expect(d.reason).toContain('clamped'); + }); + + it('rejects when the budget is exhausted', () => { + const d = checkSpendBudget({ budgetUsd: 500, state: state({ spentUsd: 500 }), orderUsd: 50, window: 'daily', now: MON }); + expect(d.allowed).toBe(false); + expect(d.allowedUsd).toBe(0); + expect(d.remainingUsd).toBe(0); + }); + + it('rejects when remaining is below the exchange min-notional', () => { + const d = checkSpendBudget({ budgetUsd: 500, state: state({ spentUsd: 499.5 }), orderUsd: 50, window: 'daily', now: MON, minOrderUsd: 1 }); + expect(d.allowed).toBe(false); + }); + + it('resets spend when the day rolls over', () => { + const d = checkSpendBudget({ budgetUsd: 500, state: state({ spentUsd: 500 }), orderUsd: 50, window: 'daily', now: TUE }); + expect(d.allowed).toBe(true); + expect(d.state.spentUsd).toBe(0); + expect(d.allowedUsd).toBe(50); + }); + + it('keeps spend within the same week but resets on a new week', () => { + const wk = freshBudgetState('weekly', MON); + const same = checkSpendBudget({ budgetUsd: 500, state: { ...wk, spentUsd: 500 }, orderUsd: 50, window: 'weekly', now: TUE }); + expect(same.allowed).toBe(false); // still same week + const next = checkSpendBudget({ budgetUsd: 500, state: { ...wk, spentUsd: 500 }, orderUsd: 50, window: 'weekly', now: NEXT_MON }); + expect(next.allowed).toBe(true); + expect(next.state.spentUsd).toBe(0); + }); + + it('resets monthly budget across a month boundary', () => { + const m = freshBudgetState('monthly', MON); + const next = checkSpendBudget({ budgetUsd: 500, state: { ...m, spentUsd: 500 }, orderUsd: 50, window: 'monthly', now: NEXT_MONTH }); + expect(next.allowed).toBe(true); + }); +}); + +describe('recordSpend', () => { + it('accumulates within a window', () => { + let s = freshBudgetState('daily', MON); + s = recordSpend(s, 100, 'daily', MON); + s = recordSpend(s, 50, 'daily', MON); + expect(s.spentUsd).toBe(150); + }); + + it('resets across a window rollover', () => { + let s = freshBudgetState('daily', MON); + s = recordSpend(s, 100, 'daily', MON); + s = recordSpend(s, 50, 'daily', TUE); + expect(s.spentUsd).toBe(50); + }); + + it('ignores non-positive amounts', () => { + let s = freshBudgetState('daily', MON); + s = recordSpend(s, -10, 'daily', MON); + s = recordSpend(s, 0, 'daily', MON); + expect(s.spentUsd).toBe(0); + }); +}); + +describe('rolloverIfNeeded', () => { + it('is a no-op within the same window', () => { + const s = freshBudgetState('daily', MON); + expect(rolloverIfNeeded({ ...s, spentUsd: 99 }, 'daily', MON).spentUsd).toBe(99); + }); +}); diff --git a/packages/source-crypto-trade/src/spend-budget.ts b/packages/source-crypto-trade/src/spend-budget.ts new file mode 100644 index 0000000..b31723b --- /dev/null +++ b/packages/source-crypto-trade/src/spend-budget.ts @@ -0,0 +1,145 @@ +/** + * Crypto spend budget — pure, deterministic budget logic. + * + * This is the single safety primitive that every BUY (engine, AI-analyzer, or + * external agent) is hard-capped against. It is intentionally dependency-free + * and side-effect-free so it can be unit tested in isolation (mirrors the + * `@b1dz/equity-engine` decision-function style) and re-used server-side by the + * agent API. + * + * The engine holds the live mutable state and a daily/weekly/monthly window; + * this module only answers "given this state + budget, may I spend `orderUsd`, + * and what's left?" plus the window-rollover arithmetic. + */ + +export type BudgetWindow = 'daily' | 'weekly' | 'monthly'; + +export const BUDGET_WINDOWS: readonly BudgetWindow[] = ['daily', 'weekly', 'monthly']; + +export function isBudgetWindow(v: unknown): v is BudgetWindow { + return typeof v === 'string' && (BUDGET_WINDOWS as readonly string[]).includes(v); +} + +/** Mutable spend state for a single budget window. */ +export interface SpendBudgetState { + /** USD spent on buys within the current window. */ + spentUsd: number; + /** Epoch ms of the start of the current window. */ + windowStart: number; +} + +/** + * Start-of-window epoch ms for `now`, in UTC. + * - daily → 00:00:00 UTC today + * - weekly → 00:00:00 UTC of the most recent Monday + * - monthly → 00:00:00 UTC on the 1st of this month + */ +export function windowStartFor(window: BudgetWindow, now: number): number { + const d = new Date(now); + if (window === 'monthly') { + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); + } + const dayStart = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + if (window === 'daily') return dayStart; + // weekly: back up to Monday (getUTCDay: 0=Sun..6=Sat → Monday=1) + const dow = new Date(dayStart).getUTCDay(); + const daysSinceMonday = (dow + 6) % 7; + return dayStart - daysSinceMonday * 86_400_000; +} + +export function freshBudgetState(window: BudgetWindow, now: number): SpendBudgetState { + return { spentUsd: 0, windowStart: windowStartFor(window, now) }; +} + +/** + * Returns the state rolled forward to the current window. If `now` has crossed + * into a new window the spend counter resets to 0; otherwise the state is + * returned unchanged. + */ +export function rolloverIfNeeded( + state: SpendBudgetState, + window: BudgetWindow, + now: number, +): SpendBudgetState { + const start = windowStartFor(window, now); + if (start !== state.windowStart) return { spentUsd: 0, windowStart: start }; + return state; +} + +export interface BudgetDecision { + /** True when `orderUsd` (or a clamped portion) is allowed. */ + allowed: boolean; + /** USD still available this window AFTER rollover (Infinity when no budget). */ + remainingUsd: number; + /** The largest amount that may actually be spent now (min of order and remaining). */ + allowedUsd: number; + /** State rolled forward to `now` (caller should persist this). */ + state: SpendBudgetState; + /** Human-readable reason for logs/UI. */ + reason: string; +} + +/** + * Core check. `budgetUsd == null` (or <= 0) means "no spend budget configured" + * → unlimited (the engine's own per-position cap still applies upstream). + * + * `minOrderUsd` is the smallest order worth placing (exchange min-notional); + * if the remaining budget is below it, the buy is rejected rather than clamped + * to dust. + */ +export function checkSpendBudget(args: { + budgetUsd: number | null | undefined; + state: SpendBudgetState; + orderUsd: number; + window: BudgetWindow; + now: number; + minOrderUsd?: number; +}): BudgetDecision { + const { budgetUsd, orderUsd, window, now } = args; + const minOrderUsd = args.minOrderUsd ?? 0; + const state = rolloverIfNeeded(args.state, window, now); + + if (budgetUsd == null || !Number.isFinite(budgetUsd) || budgetUsd <= 0) { + return { + allowed: true, + remainingUsd: Infinity, + allowedUsd: orderUsd, + state, + reason: 'no spend budget configured', + }; + } + + const remaining = Math.max(0, budgetUsd - state.spentUsd); + if (remaining < Math.max(minOrderUsd, 0.000001)) { + return { + allowed: false, + remainingUsd: remaining, + allowedUsd: 0, + state, + reason: `budget: $${state.spentUsd.toFixed(2)} of $${budgetUsd.toFixed(2)} spent this ${window} — none remaining`, + }; + } + + const allowedUsd = Math.min(orderUsd, remaining); + return { + allowed: true, + remainingUsd: remaining, + allowedUsd, + state, + reason: allowedUsd < orderUsd + ? `budget: clamped $${orderUsd.toFixed(2)} → $${allowedUsd.toFixed(2)} ($${remaining.toFixed(2)} left this ${window})` + : `budget: $${remaining.toFixed(2)} of $${budgetUsd.toFixed(2)} left this ${window}`, + }; +} + +/** Record a confirmed spend, returning the new (rolled-forward) state. */ +export function recordSpend( + state: SpendBudgetState, + usd: number, + window: BudgetWindow, + now: number, +): SpendBudgetState { + const next = rolloverIfNeeded(state, window, now); + if (!Number.isFinite(usd) || usd <= 0) return next; + return { spentUsd: next.spentUsd + usd, windowStart: next.windowStart }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4861ea5..24956cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,7 +28,7 @@ importers: version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: latest - version: 4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + version: 4.1.7(@types/node@25.9.3)(vite@8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) apps/cli: dependencies: @@ -138,9 +138,15 @@ importers: '@b1dz/adapters-solana': specifier: workspace:* version: link:../../packages/adapters-solana + '@b1dz/ai-analyzer': + specifier: workspace:* + version: link:../../packages/ai-analyzer '@b1dz/core': specifier: workspace:* version: link:../../packages/core + '@b1dz/equity-engine': + specifier: workspace:* + version: link:../../packages/equity-engine '@b1dz/event-channel': specifier: workspace:* version: link:../../packages/event-channel @@ -150,9 +156,6 @@ importers: '@b1dz/observe-engine': specifier: workspace:* version: link:../../packages/observe-engine - '@b1dz/equity-engine': - specifier: workspace:* - version: link:../../packages/equity-engine '@b1dz/source-alpaca': specifier: workspace:* version: link:../../packages/source-alpaca @@ -364,6 +367,22 @@ importers: specifier: latest version: 4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + packages/ai-analyzer: + dependencies: + '@b1dz/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: latest + version: 25.9.3 + typescript: + specifier: latest + version: 6.0.3 + vitest: + specifier: latest + version: 4.1.9(@types/node@25.9.3)(vite@8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + packages/core: dependencies: '@profullstack/pluginstore': @@ -515,15 +534,27 @@ importers: specifier: latest version: 4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) - packages/source-strategies: + packages/source-crypto-arb: dependencies: + '@b1dz/adapters-evm': + specifier: workspace:* + version: link:../adapters-evm '@b1dz/core': specifier: workspace:* version: link:../core + undici: + specifier: ^8.0.2 + version: 8.0.2 + ws: + specifier: ^8.20.0 + version: 8.20.0 devDependencies: '@types/node': specifier: latest version: 25.9.1 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 typescript: specifier: latest version: 6.0.3 @@ -531,11 +562,14 @@ importers: specifier: latest version: 4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) - packages/source-ibkr: + packages/source-crypto-trade: dependencies: '@b1dz/core': specifier: workspace:* version: link:../core + '@b1dz/source-crypto-arb': + specifier: workspace:* + version: link:../source-crypto-arb devDependencies: '@types/node': specifier: latest @@ -547,11 +581,23 @@ importers: specifier: latest version: 4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) - packages/source-schwab: + packages/source-dealdash: dependencies: '@b1dz/core': specifier: workspace:* version: link:../core + got-scraping: + specifier: ^4.2.1 + version: 4.2.1 + puppeteer: + specifier: latest + version: 25.0.4(typescript@6.0.3) + puppeteer-extra: + specifier: latest + version: 3.3.6(puppeteer-core@25.0.4)(puppeteer@25.0.4(typescript@6.0.3)) + puppeteer-extra-plugin-stealth: + specifier: latest + version: 2.11.2(puppeteer-extra@3.3.6(puppeteer-core@25.0.4)(puppeteer@25.0.4(typescript@6.0.3))) devDependencies: '@types/node': specifier: latest @@ -563,7 +609,7 @@ importers: specifier: latest version: 4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) - packages/source-tradestation: + packages/source-ibkr: dependencies: '@b1dz/core': specifier: workspace:* @@ -579,7 +625,7 @@ importers: specifier: latest version: 4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) - packages/source-tradier: + packages/source-schwab: dependencies: '@b1dz/core': specifier: workspace:* @@ -595,7 +641,7 @@ importers: specifier: latest version: 4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) - packages/source-webull: + packages/source-strategies: dependencies: '@b1dz/core': specifier: workspace:* @@ -611,27 +657,15 @@ importers: specifier: latest version: 4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) - packages/source-crypto-arb: + packages/source-tradestation: dependencies: - '@b1dz/adapters-evm': - specifier: workspace:* - version: link:../adapters-evm '@b1dz/core': specifier: workspace:* version: link:../core - undici: - specifier: ^8.0.2 - version: 8.0.2 - ws: - specifier: ^8.20.0 - version: 8.20.0 devDependencies: '@types/node': specifier: latest version: 25.9.1 - '@types/ws': - specifier: ^8.18.1 - version: 8.18.1 typescript: specifier: latest version: 6.0.3 @@ -639,14 +673,11 @@ importers: specifier: latest version: 4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) - packages/source-crypto-trade: + packages/source-tradier: dependencies: '@b1dz/core': specifier: workspace:* version: link:../core - '@b1dz/source-crypto-arb': - specifier: workspace:* - version: link:../source-crypto-arb devDependencies: '@types/node': specifier: latest @@ -658,23 +689,11 @@ importers: specifier: latest version: 4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) - packages/source-dealdash: + packages/source-webull: dependencies: '@b1dz/core': specifier: workspace:* version: link:../core - got-scraping: - specifier: ^4.2.1 - version: 4.2.1 - puppeteer: - specifier: latest - version: 25.0.4(typescript@6.0.3) - puppeteer-extra: - specifier: latest - version: 3.3.6(puppeteer-core@25.0.4)(puppeteer@25.0.4(typescript@6.0.3)) - puppeteer-extra-plugin-stealth: - specifier: latest - version: 2.11.2(puppeteer-extra@3.3.6(puppeteer-core@25.0.4)(puppeteer@25.0.4(typescript@6.0.3))) devDependencies: '@types/node': specifier: latest @@ -1699,6 +1718,9 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/node@25.9.3': + resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1772,6 +1794,9 @@ packages: '@vitest/expect@4.1.7': resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@vitest/mocker@4.1.7': resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} peerDependencies: @@ -1783,21 +1808,47 @@ packages: vite: optional: true + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@4.1.7': resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@vitest/runner@4.1.7': resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@vitest/snapshot@4.1.7': resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/spy@4.1.7': resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/utils@4.1.7': resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -3197,6 +3248,47 @@ packages: jsdom: optional: true + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + webdriver-bidi-protocol@0.4.1: resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} @@ -3890,6 +3982,10 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/node@25.9.3': + dependencies: + undici-types: 7.24.6 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -3900,7 +3996,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.9.1 + '@types/node': 25.9.3 '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: @@ -4002,6 +4098,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.7(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3))': dependencies: '@vitest/spy': 4.1.7 @@ -4010,15 +4115,40 @@ snapshots: optionalDependencies: vite: 8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + '@vitest/mocker@4.1.7(vite@8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + + '@vitest/mocker@4.1.9(vite@8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + '@vitest/pretty-format@4.1.7': dependencies: tinyrainbow: 3.1.0 + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@4.1.7': dependencies: '@vitest/utils': 4.1.7 pathe: 2.0.3 + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + '@vitest/snapshot@4.1.7': dependencies: '@vitest/pretty-format': 4.1.7 @@ -4026,14 +4156,29 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@4.1.7': {} + '@vitest/spy@4.1.9': {} + '@vitest/utils@4.1.7': dependencies: '@vitest/pretty-format': 4.1.7 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + abbrev@1.1.1: {} abitype@1.2.3(typescript@6.0.3)(zod@3.25.76): @@ -5385,6 +5530,20 @@ snapshots: jiti: 2.7.0 tsx: 4.22.3 + vite@8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.0-rc.13 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.9.3 + esbuild: 0.28.0 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.22.3 + vitest@4.1.7(@types/node@25.9.1)(vite@8.0.7(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)): dependencies: '@vitest/expect': 4.1.7 @@ -5412,6 +5571,60 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.7(@types/node@25.9.3)(vite@8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.3 + transitivePeerDependencies: + - msw + + vitest@4.1.9(@types/node@25.9.3)(vite@8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.0.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.7(@types/node@25.9.3)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.3 + transitivePeerDependencies: + - msw + webdriver-bidi-protocol@0.4.1: {} which@2.0.2: diff --git a/supabase/migrations/20260618130000_crypto_spend_ledger.sql b/supabase/migrations/20260618130000_crypto_spend_ledger.sql new file mode 100644 index 0000000..2870734 --- /dev/null +++ b/supabase/migrations/20260618130000_crypto_spend_ledger.sql @@ -0,0 +1,35 @@ +-- Crypto spend ledger — the durable record of every BUY the platform executes +-- on a user's behalf (engine, AI analyzer, or external agent). The rolling +-- spend budget is summed from this table per window, so the cap survives daemon +-- restarts and is shared across the engine and the agent API. +-- +-- RLS owner-only (the ensure_rls event trigger auto-enables RLS on new tables; +-- we add the owner policy explicitly here). + +create table if not exists public.crypto_spend_ledger ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + ts timestamptz not null default now(), + source text not null default 'engine' check (source in ('engine', 'ai', 'agent')), + exchange text, + pair text, + usd numeric not null check (usd >= 0), + -- set when an agent token initiated the spend, so per-token budgets can be + -- enforced by summing this column filtered by agent_token_id. + agent_token_id uuid, + created_at timestamptz not null default now() +); + +create index if not exists crypto_spend_ledger_user_ts_idx + on public.crypto_spend_ledger (user_id, ts desc); +create index if not exists crypto_spend_ledger_token_ts_idx + on public.crypto_spend_ledger (agent_token_id, ts desc) + where agent_token_id is not null; + +alter table public.crypto_spend_ledger enable row level security; + +drop policy if exists "spend_ledger_owner" on public.crypto_spend_ledger; +create policy "spend_ledger_owner" on public.crypto_spend_ledger + for all + using (user_id = auth.uid()) + with check (user_id = auth.uid()); diff --git a/supabase/migrations/20260618130100_agent_tokens.sql b/supabase/migrations/20260618130100_agent_tokens.sql new file mode 100644 index 0000000..cfc7271 --- /dev/null +++ b/supabase/migrations/20260618130100_agent_tokens.sql @@ -0,0 +1,58 @@ +-- Agent tokens — "Coinbase for Agents" analog. Each row is a scoped credential +-- ("sub-account") that an external AI system (Claude, ChatGPT, an MCP client) +-- presents to trade on the user's behalf, hard-capped by its own spend budget. +-- +-- Only a hash of the token is stored (the plaintext is shown once at creation). +-- Per-token spend is enforced by summing crypto_spend_ledger filtered by +-- agent_token_id against budget_usd over budget_window. + +create table if not exists public.agent_tokens ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + name text not null, + -- sha-256 hex of the plaintext token (b1dz_agent_). Never store plain. + token_hash text not null unique, + -- last 4 chars of the plaintext, for UI display ("…a1b2"). + token_suffix text not null, + scopes text[] not null default '{read}', + budget_usd numeric not null default 0 check (budget_usd >= 0), + budget_window text not null default 'daily' check (budget_window in ('daily', 'weekly', 'monthly')), + -- optional symbol allowlist (e.g. {BTC-USD,ETH-USD}); null/empty = any. + allowed_symbols text[], + revoked_at timestamptz, + last_used_at timestamptz, + created_at timestamptz not null default now() +); + +create index if not exists agent_tokens_user_idx on public.agent_tokens (user_id); +create unique index if not exists agent_tokens_hash_idx on public.agent_tokens (token_hash); + +alter table public.agent_tokens enable row level security; + +drop policy if exists "agent_tokens_owner" on public.agent_tokens; +create policy "agent_tokens_owner" on public.agent_tokens + for all + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + +-- Audit log of every action an agent token performed (read or trade). Append- +-- only from the app; owner can read their own. +create table if not exists public.agent_actions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + agent_token_id uuid references public.agent_tokens(id) on delete set null, + action text not null, + detail jsonb, + ok boolean not null default true, + ts timestamptz not null default now() +); + +create index if not exists agent_actions_user_ts_idx on public.agent_actions (user_id, ts desc); + +alter table public.agent_actions enable row level security; + +drop policy if exists "agent_actions_owner" on public.agent_actions; +create policy "agent_actions_owner" on public.agent_actions + for all + using (user_id = auth.uid()) + with check (user_id = auth.uid());