import { initTRPC, TRPCError } from '@trpc/server'; import { z } from 'zod'; import type { SQLInputValue } from 'node:sqlite'; import { randomUUID } from 'node:crypto'; import type { Context } from './context.ts'; import { hashPassword, verifyPassword, createSession, clearCookie, oauthStateCookie, verifyOAuthState, OAUTH_STATE_COOKIE } from './context.ts'; import { generateBase32Secret, totp as computeTotp, verifyTotp, otpauthUrl } from '../auth/totp.ts'; import { generateBackupCodes, hashBackupCode } from '../auth/backup-codes.ts'; import { buildAuthorizeUrl, generateState, exchangeCode, type OAuthProvider } from '../auth/oauth.ts'; import { STARTER_WATCHLIST, starterPackFor, resolveWorkspaceDefaults, defaultDrawdownTolerancePct, defaultRiskTolerance, ONBOARDING_DISCLAIMER, type Complexity, type Density, type ExperienceStage, type Goal, type Horizon, type JargonComfort, } from '../onboarding/starter.ts'; import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts'; import type { SymbolUniverseData } from '../screener/UniverseEvaluator.ts'; import type { ThesisEvent } from '../thesis/ThesisMonitor.ts'; import type { XCookieHealth } from '../adapters/XCookieAdapter.ts'; import { emaFromCandles, rsi as rsiFn, relativeVolume, macd as macdFn } from '../analysis/indicators.ts'; import { listUsers, resetPassword, gdprExport, queueHealth, alertStatus, resetQueueBackoff, NotOwnerError, listUserSessions, listAuditLog, queueSecFetch, setUserModules, disableUser, enableUser, deleteUser, getFinraBaseUrl, setFinraBaseUrl, recordAudit } from '../admin/admin.ts'; import { restartServers, type RestartTarget } from '../lib/restart.ts'; import type { LintResult } from '../services/secDataFetcher.ts'; import { computeOwnershipPercentages, fetchAndPersistFloat } from '../services/stockFloatService.ts'; import { EdgarAdapter } from '../adapters/EdgarAdapter.ts'; import { OptionsAdapter, parseOptionChainRows } from '../adapters/OptionsAdapter.ts'; import type { OptionChainRow, OptionGreeks } from '../adapters/OptionsAdapter.ts'; import { CONFLUENCE_SLOTS, slotsByFamily, type SlotFamily } from '../confluence/confluenceSlots.ts'; import { CONFLUENCE_SLOT_IDS } from '../confluence/confluenceSlots.ts'; import { ConfluenceRepository, rackFromSlots } from '../db/confluenceRepository.ts'; import { runSlotBacktest, signalHistoryToStats, type ConfluenceFireEvent } from '../confluence/confluenceBacktest.ts'; import { detectPictureChange } from '../confluence/confluenceRack.ts'; // --------------------------------------------------------------------------- // X cookie credential helpers. Loads AES-256-GCM encrypted ct0/auth_token from // DB and decrypts them per-request. Returns null when not configured. // --------------------------------------------------------------------------- type XCredentials = { ct0: string; auth_token: string }; async function loadXCredentials(ctx: Context): Promise { const row = ctx.db.prepare('SELECT ct0_enc, auth_token_enc FROM x_credentials WHERE id=?').get('singleton') as { ct0_enc?: string | null; auth_token_enc?: string | null } | undefined; if (!row || !row.ct0_enc || !row.auth_token_enc) return null; const encryptMod = await import('../lib/crypto.ts'); try { return { ct0: encryptMod.default.decrypt(row.ct0_enc), auth_token: encryptMod.default.decrypt(row.auth_token_enc) }; } catch { return null; } // decrypt failure — treat as unconfigured. } function updateXHealth(db: any, status: 'healthy' | 'degraded' | 'failed', err?: string): void { try { db.prepare( `INSERT INTO x_credentials (id, healthy, last_error, updated_at) VALUES ('singleton', ?, ?, ?) ON CONFLICT(id) DO UPDATE SET healthy=excluded.healthy, last_error=excluded.last_error, updated_at=excluded.updated_at` ).run(status === 'healthy' ? 1 : 0, err ?? null, new Date().toISOString()); } catch { /* ignore — health tracking is best-effort */ } } const t = initTRPC.context().create(); const router = t.router; const publicProcedure = t.procedure; const protectedProcedure = publicProcedure.use(({ ctx, next }) => { if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Sign in required.' }); const row = ctx.db.prepare('SELECT status FROM users WHERE id=?').get(ctx.userId) as { status: string } | undefined; if (!row) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not found.' }); if (row.status !== 'active') throw new TRPCError({ code: 'FORBIDDEN', message: row.status === 'pending_approval' ? 'Account pending admin approval.' : 'Account disabled.' }); return next({ ctx }); }); // Admin-guarded procedures (Slice 25). Non-admin/anonymous → 403. const adminProcedure = protectedProcedure.use(({ ctx, next }) => { const row = ctx.db.prepare('SELECT is_admin FROM users WHERE id=?').get(ctx.userId!) as { is_admin: number | null } | undefined; if (!row || !row.is_admin) throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required.' }); return next({ ctx }); }); function oauthCreds(provider: OAuthProvider): { clientId?: string; clientSecret?: string } { if (provider === 'github') return { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET }; return { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET }; } const authRouter = router({ signup: publicProcedure .input(z.object({ email: z.string().email(), password: z.string().min(8) })) .mutation(async ({ ctx, input }) => { const email = input.email.toLowerCase(); const existing = ctx.db.prepare('SELECT id FROM users WHERE email=?').get(email); if (existing) throw new TRPCError({ code: 'CONFLICT', message: 'That email is already registered.' }); const userId = randomUUID(); ctx.db.prepare('INSERT INTO users (id,email,pw_hash,status,created_at) VALUES (?,?,?,?,?)').run(userId, email, hashPassword(input.password), 'pending_approval', new Date().toISOString()); return { userId, pending: true, message: 'Account created. An admin must approve your account before you can sign in.' }; }), login: publicProcedure .input(z.object({ email: z.string().email(), password: z.string(), totp: z.string().optional() })) .mutation(async ({ ctx, input }) => { const email = input.email.toLowerCase(); const row = ctx.db.prepare('SELECT id, pw_hash, is_2fa_enabled, totp_secret, status FROM users WHERE email=?').get(email) as { id: string; pw_hash: string; is_2fa_enabled: number; totp_secret: string | null; status: string } | undefined; if (!row || !verifyPassword(input.password, row.pw_hash)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid email or password.' }); if (row.status !== 'active') throw new TRPCError({ code: 'FORBIDDEN', message: row.status === 'pending_approval' ? 'Account pending admin approval.' : 'Account disabled.' }); if (row.is_2fa_enabled === 1) { if (!input.totp || !row.totp_secret || !verifyTotp(input.totp, row.totp_secret)) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Two-factor code required or invalid.' }); } } const { cookie } = createSession(ctx.db, row.id); ctx.resHeaders.append('Set-Cookie', cookie); return { userId: row.id }; }), logout: publicProcedure.mutation(({ ctx }) => { ctx.resHeaders.append('Set-Cookie', clearCookie()); return { ok: true }; }), me: publicProcedure.query(({ ctx }) => { if (!ctx.userId) return null; const u = ctx.db.prepare( 'SELECT id,email,complexity,risk_tolerance,convexity_posture,modules,experience_stage,goal,horizon,density,jargon_comfort FROM users WHERE id=?', ).get(ctx.userId) as { id: string; email: string; complexity: string; risk_tolerance: string; convexity_posture: string; modules: string; experience_stage: string | null; goal: string | null; horizon: string | null; density: string | null; jargon_comfort: string | null; } | undefined; const wl = ctx.db.prepare('SELECT 1 FROM watchlists WHERE owner_id=? LIMIT 1').get(ctx.userId); let modules: string[] = ['research', 'settings']; try { modules = JSON.parse(u?.modules ?? '["research","settings"]'); } catch { /* keep default */ } if (u?.id) { const adminRow = ctx.db.prepare('SELECT is_admin FROM users WHERE id=?').get(u.id) as { is_admin: number } | undefined; if (adminRow?.is_admin && !modules.includes('admin')) modules.push('admin'); } const density = (u?.density === 'standard' || u?.density === 'full' || u?.density === 'focused') ? u.density : (u?.complexity === 'advanced' ? 'full' : u?.complexity === 'intermediate' ? 'standard' : 'focused'); return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture, onboarded: !!wl, modules, experienceStage: u.experience_stage, goal: u.goal, horizon: u.horizon, density, jargonComfort: u.jargon_comfort ?? 'plain', } : null; }), enable2fa: protectedProcedure.input(z.object({})).mutation(async ({ ctx }) => { const userId = ctx.userId as string; const u = ctx.db.prepare('SELECT email FROM users WHERE id=?').get(userId) as { email: string } | undefined; const secret = generateBase32Secret(); const codes = generateBackupCodes(10); const hashes = codes.map((c) => hashBackupCode(c)); ctx.db.prepare('UPDATE users SET totp_secret=?, backup_codes_hashed=? WHERE id=?').run(secret, JSON.stringify(hashes), userId); return { totpSecret: secret, qrUrl: otpauthUrl(secret, 'Investor Flow', u?.email ?? 'user'), backupCodes: codes }; }), confirm2fa: protectedProcedure.input(z.object({ totp: z.string() })).mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const row = ctx.db.prepare('SELECT totp_secret FROM users WHERE id=?').get(userId) as { totp_secret: string | null } | undefined; if (!row?.totp_secret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Enable two-factor first.' }); if (!verifyTotp(input.totp, row.totp_secret)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid two-factor code.' }); ctx.db.prepare('UPDATE users SET is_2fa_enabled=1 WHERE id=?').run(userId); return { ok: true }; }), // Slice 2b — OAuth start: return the provider authorize URL + set a CSRF state cookie. oauthStart: publicProcedure .input(z.object({ provider: z.enum(['github', 'google']), redirectUri: z.string().url() })) .mutation(({ ctx, input }) => { const creds = oauthCreds(input.provider); if (!creds.clientId || !creds.clientSecret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'OAuth provider not configured.' }); const state = generateState(); ctx.resHeaders.append('Set-Cookie', oauthStateCookie(input.provider, state, input.redirectUri)); return { redirectUrl: buildAuthorizeUrl(input.provider, { clientId: creds.clientId, redirectUri: input.redirectUri, state }), state }; }), // Slice 2b — OAuth callback: verify CSRF state, exchange code, find/link/create user, start a session. oauthCallback: publicProcedure .input(z.object({ provider: z.enum(['github', 'google']), code: z.string(), state: z.string(), redirectUri: z.string().url() })) .mutation(async ({ ctx, input }) => { if (!verifyOAuthState(ctx.cookies[OAUTH_STATE_COOKIE], input.provider, input.state, input.redirectUri)) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid OAuth state.' }); } const creds = oauthCreds(input.provider); if (!creds.clientId || !creds.clientSecret) throw new TRPCError({ code: 'BAD_REQUEST', message: 'OAuth provider not configured.' }); const info = await exchangeCode(input.provider, input.code, { clientId: creds.clientId, clientSecret: creds.clientSecret, redirectUri: input.redirectUri }); const subject = info.providerSubject; const email = info.email.toLowerCase(); if (!subject || !email) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Provider did not return a usable identity.' }); // existing link? let row = ctx.db.prepare('SELECT id FROM users WHERE oauth_subject=? AND oauth_provider=?').get(subject, input.provider) as { id: string } | undefined; if (!row) { // link existing account by email, else create an OAuth-only account (sentinel pw_hash) const byEmail = ctx.db.prepare('SELECT id FROM users WHERE email=?').get(email) as { id: string } | undefined; const userId = byEmail?.id ?? randomUUID(); if (byEmail) { ctx.db.prepare('UPDATE users SET oauth_subject=?, oauth_provider=? WHERE id=?').run(subject, input.provider, userId); } else { ctx.db.prepare('INSERT INTO users (id,email,pw_hash,oauth_subject,oauth_provider,created_at) VALUES (?,?,?,?,?,?)').run(userId, email, 'oauth', subject, input.provider, new Date().toISOString()); } row = { id: userId }; } const { cookie } = createSession(ctx.db, row.id); ctx.resHeaders.append('Set-Cookie', cookie); return { userId: row.id }; }), }); const onboardingRouter = router({ // Public: starter symbols for a density (default focused). Used in workspace setup preview. starter: publicProcedure .input(z.object({ density: z.enum(['focused', 'standard', 'full']).optional(), experienceStage: z.enum(['never_invested', 'some_stocks', 'active_self_directed']).optional(), }).optional()) .query(({ input }) => { const density: Density = input?.density ?? (input?.experienceStage === 'active_self_directed' ? 'full' : input?.experienceStage === 'some_stocks' ? 'standard' : 'focused'); const pack = starterPackFor(density); return { watchlist: pack, density, disclaimer: ONBOARDING_DISCLAIMER }; }), // Protected: interview → workspace profile + first watchlist + optional portfolio. complete: protectedProcedure .input(z.object({ // Preferred interview path experienceStage: z.enum(['never_invested', 'some_stocks', 'active_self_directed']).optional(), goal: z.enum(['grow', 'income', 'protect']).optional(), horizon: z.enum(['short', 'medium', 'long']).optional(), density: z.enum(['focused', 'standard', 'full']).optional(), jargonComfort: z.enum(['plain', 'mixed', 'technical']).optional(), // Legacy path (still accepted) complexity: z.enum(['beginner', 'intermediate', 'advanced']).optional(), riskTolerance: z.enum(['conservative', 'moderate', 'aggressive']).optional(), drawdownTolerancePct: z.number().optional(), firstWatchlistSymbols: z.array(z.string()).optional(), portfolio: z.array(z.object({ symbol: z.string(), qty: z.number(), avgCost: z.number(), acquiredAt: z.string() })).optional(), })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const experienceStage: ExperienceStage = input.experienceStage ?? (input.complexity === 'advanced' ? 'active_self_directed' : input.complexity === 'intermediate' ? 'some_stocks' : 'never_invested'); const defaults = resolveWorkspaceDefaults({ experienceStage, goal: input.goal as Goal | undefined, horizon: input.horizon as Horizon | undefined, density: input.density as Density | undefined, jargonComfort: input.jargonComfort as JargonComfort | undefined, }); const complexity = (input.complexity ?? defaults.complexity) as Complexity; const riskTolerance = input.riskTolerance ?? defaults.riskTolerance; const drawdown = input.drawdownTolerancePct ?? defaults.drawdownTolerancePct; ctx.db.prepare( `UPDATE users SET complexity=?, risk_tolerance=?, drawdown_tolerance=?, experience_stage=?, goal=?, horizon=?, density=?, jargon_comfort=? WHERE id=?`, ).run( complexity, riskTolerance, drawdown, defaults.experienceStage, defaults.goal, defaults.horizon, defaults.density, defaults.jargonComfort, userId, ); const pack = defaults.starterPack; const symbols = input.firstWatchlistSymbols ?? pack.map((s) => s.symbol); const existingWl = ctx.db.prepare('SELECT id FROM watchlists WHERE owner_id=? LIMIT 1').get(userId) as { id: string } | undefined; let wlId = existingWl?.id; if (!wlId) { wlId = randomUUID(); ctx.db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?,?,?,?,?,?)').run( wlId, userId, 'default', JSON.stringify(symbols), new Date().toISOString(), 0, ); } const allKnown = [...STARTER_WATCHLIST, ...starterPackFor('full')]; for (const sym of symbols) { const kind = (allKnown.find((s) => s.symbol === sym)?.tickerKind ?? pack.find((s) => s.symbol === sym)?.tickerKind ?? 'equity') as 'equity' | 'crypto' | 'etf' | 'index'; try { await ctx.cache.subscribe(sym, kind); } catch { /* ignore */ } try { queueSecFetch(ctx.db, sym); } catch { /* ignore */ } } try { const { materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts'); materializeClassificationWatchlists(ctx.db, userId); } catch { /* ignore — sector data not available yet */ } if (input.portfolio) { const ins = ctx.db.prepare('INSERT INTO portfolio_holdings (id, owner_id, symbol, qty, avg_cost, acquired_at, status) VALUES (?,?,?,?,?,?,?)'); for (const h of input.portfolio) ins.run(randomUUID(), userId, h.symbol.toUpperCase(), h.qty, h.avgCost, h.acquiredAt, 'open'); } return { ok: true, watchlistId: wlId, density: defaults.density, complexity, experienceStage: defaults.experienceStage, }; }), // Protected: update workspace profile after onboarding (density, goal, jargon) without re-seeding watchlist. updateProfile: protectedProcedure .input(z.object({ experienceStage: z.enum(['never_invested', 'some_stocks', 'active_self_directed']).optional(), goal: z.enum(['grow', 'income', 'protect']).optional(), horizon: z.enum(['short', 'medium', 'long']).optional(), density: z.enum(['focused', 'standard', 'full']).optional(), jargonComfort: z.enum(['plain', 'mixed', 'technical']).optional(), })) .mutation(({ ctx, input }) => { const userId = ctx.userId as string; const row = ctx.db.prepare( 'SELECT experience_stage, goal, horizon, density, jargon_comfort, complexity FROM users WHERE id=?', ).get(userId) as { experience_stage: string | null; goal: string | null; horizon: string | null; density: string | null; jargon_comfort: string | null; complexity: string; } | undefined; if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); const experienceStage = (input.experienceStage ?? row.experience_stage ?? 'never_invested') as ExperienceStage; const density = (input.density ?? row.density ?? 'focused') as Density; const goal = (input.goal ?? row.goal ?? 'grow') as Goal; const horizon = (input.horizon ?? row.horizon ?? 'long') as Horizon; const jargonComfort = (input.jargonComfort ?? row.jargon_comfort ?? 'plain') as JargonComfort; const complexity = complexityFromExperienceSafe(experienceStage); ctx.db.prepare( `UPDATE users SET experience_stage=?, goal=?, horizon=?, density=?, jargon_comfort=?, complexity=? WHERE id=?`, ).run(experienceStage, goal, horizon, density, jargonComfort, complexity, userId); return { ok: true, experienceStage, goal, horizon, density, jargonComfort, complexity }; }), }); function complexityFromExperienceSafe(stage: ExperienceStage): Complexity { if (stage === 'active_self_directed') return 'advanced'; if (stage === 'some_stocks') return 'intermediate'; return 'beginner'; } const marketRouter = router({ snapshot: publicProcedure .input(z.object({ symbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); const k = { quote: `yfinance:quote:${symbol}`, candles: `yfinance:candles:${symbol}:1d`, sector: `yfinance:symbol:${symbol}` }; const entries = await ctx.cache.getMany([k.quote, k.candles, k.sector]); const byKey = new Map(entries.map((e) => [e.key, e])); const val = (key: string): T | null => (byKey.get(key)?.value ?? null) as T | null; const stale = (key: string): boolean => byKey.get(key)?.isStale ?? true; return { symbol, quote: val(k.quote), candles: val(k.candles), sector: val(k.sector), stale: { quote: stale(k.quote), candles: stale(k.candles), sector: stale(k.sector) }, }; }), /** * Batched snapshot for multiple symbols — reduces N+1 API calls. * Used by portfolio/watchlist views that need quotes for many symbols at once. */ snapshots: publicProcedure .input(z.object({ symbols: z.array(z.string().min(1)).max(50) })) .query(async ({ ctx, input }) => { const symbols = input.symbols.map((s) => s.toUpperCase()); // Build all cache keys at once const keys = symbols.flatMap((symbol) => [ `yfinance:quote:${symbol}`, `yfinance:candles:${symbol}:1d`, `yfinance:symbol:${symbol}`, ]); // Batch read all at once const entries = await ctx.cache.getMany(keys); const byKey = new Map(entries.map((e) => [e.key, e])); // Group results by symbol const results: Array<{ symbol: string; quote: Quote | null; candles: PriceCandle[] | null; sector: SymbolMeta | null; stale: { quote: boolean; candles: boolean; sector: boolean }; }> = []; for (const symbol of symbols) { const kQuote = `yfinance:quote:${symbol}`; const kCandles = `yfinance:candles:${symbol}:1d`; const kSector = `yfinance:symbol:${symbol}`; results.push({ symbol, quote: byKey.get(kQuote)?.value as Quote | null ?? null, candles: byKey.get(kCandles)?.value as PriceCandle[] | null ?? null, sector: byKey.get(kSector)?.value as SymbolMeta | null ?? null, stale: { quote: byKey.get(kQuote)?.isStale ?? true, candles: byKey.get(kCandles)?.isStale ?? true, sector: byKey.get(kSector)?.isStale ?? true, }, }); } return results; }), /** * Focused-ticker context: performance vs market, sector/theme proxies, and peers. * Educational relative snapshot (ADR-0007). */ tickerContext: publicProcedure .input(z.object({ symbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const { returnsBundle, buildTickerContext, relativeTo, resolveBusinessContext, } = await import('../analysis/tickerContext.ts'); const { MARKET_ROTATION_UNIVERSE, BENCHMARK_SYMBOL, buildSectorRsMap, } = await import('../analysis/marketRotationRs.ts'); const { classifyRegime } = await import('../macro/MacroRegime.ts'); const symbol = input.symbol.toUpperCase(); try { await ctx.cache.ensureInDemand(symbol, 'equity'); } catch { /* ignore */ } try { await ctx.cache.ensureInDemand(BENCHMARK_SYMBOL, 'etf'); } catch { /* ignore */ } const metaEntry = await ctx.cache.get(`yfinance:symbol:${symbol}`); const meta = metaEntry.value; const name = meta?.name ?? null; const sectorName = meta?.sector ?? null; const industry = meta?.industry ?? null; const tickerKind = meta?.tickerKind ?? 'equity'; const description = meta?.description ?? null; // Prefer operating-profile resolution over raw Yahoo GICS (fixes IREN→XLF etc.). const biz = resolveBusinessContext({ symbol, sector: sectorName, industry, description, }); const sectorMap = biz.sectorEtf ? { etf: biz.sectorEtf, label: biz.sectorLabel ?? biz.sectorEtf } : null; const themeMap = biz.themeEtf ? { etf: biz.themeEtf, label: biz.themeLabel ?? biz.themeEtf } : null; // Peers: curated override first, then vendor peers, then sector-ETF holdings cache (never live Yahoo on request path). let peerSymbols: string[] = biz.peers.length > 0 ? biz.peers.filter((p) => p !== symbol) : Array.isArray(meta?.peers) ? (meta!.peers as string[]).map((p) => String(p).toUpperCase()).filter((p) => p && p !== symbol) : []; if (peerSymbols.length === 0 && sectorMap && !biz.blockSectorEtfPeers) { try { const holdEntry = await ctx.cache.get<{ holdings?: Array<{ symbol?: string }> }>( `yfinance:topHoldings:${sectorMap.etf}`, ); const holdings = holdEntry.value?.holdings ?? []; peerSymbols = holdings .map((h) => (h.symbol ?? '').toUpperCase()) .filter((p) => p && p !== symbol) .slice(0, 8); // Also try kv_cache layout used by sectorHoldings if (peerSymbols.length === 0) { const row = ctx.db.prepare('SELECT value FROM kv_cache WHERE key=?').get(`etf:topHoldings:${sectorMap.etf}`) as | { value: string } | undefined; if (row?.value) { const parsed = JSON.parse(row.value) as { holdings?: Array<{ symbol?: string }> }; peerSymbols = (parsed.holdings ?? []) .map((h) => (h.symbol ?? '').toUpperCase()) .filter((p) => p && p !== symbol) .slice(0, 8); } } } catch { /* cache peer pull optional */ } } const symbolsToLoad = Array.from(new Set([ symbol, BENCHMARK_SYMBOL, ...(sectorMap ? [sectorMap.etf] : []), ...(themeMap ? [themeMap.etf] : []), ...peerSymbols.slice(0, 8), ])); for (const s of symbolsToLoad) { const kind = s === BENCHMARK_SYMBOL || MARKET_ROTATION_UNIVERSE.some((u) => u.symbol === s) ? 'etf' : 'equity'; try { await ctx.cache.ensureInDemand(s, kind); } catch { /* ignore */ } } const candleKeys = symbolsToLoad.map((s) => `yfinance:candles:${s}:1d`); const candleEntries = await ctx.cache.getMany(candleKeys); const candlesBySym = new Map(); symbolsToLoad.forEach((s, i) => { candlesBySym.set(s, (candleEntries[i]?.value ?? []) as PriceCandle[]); }); const toPts = (arr: PriceCandle[]) => arr.map((c) => ({ ts: c.ts, c: c.c, v: c.v })); const symbolReturns = returnsBundle(toPts(candlesBySym.get(symbol) ?? [])); const marketReturns = returnsBundle(toPts(candlesBySym.get(BENCHMARK_SYMBOL) ?? [])); const sectorReturns = sectorMap ? returnsBundle(toPts(candlesBySym.get(sectorMap.etf) ?? [])) : null; const themeReturns = themeMap ? returnsBundle(toPts(candlesBySym.get(themeMap.etf) ?? [])) : null; // Sector leadership from RS map when possible. let sectorLeadership: 'leading' | 'lagging' | 'inline' | 'unknown' | null = null; try { const rotSymbols = [BENCHMARK_SYMBOL, ...MARKET_ROTATION_UNIVERSE.map((u) => u.symbol)]; const rotKeys = rotSymbols.map((s) => `yfinance:candles:${s}:1d`); const rotEntries = await ctx.cache.getMany(rotKeys); const map: Record = {}; rotSymbols.forEach((s, i) => { map[s] = (rotEntries[i]?.value ?? []) as PriceCandle[]; }); const rows = buildSectorRsMap(MARKET_ROTATION_UNIVERSE, map, map[BENCHMARK_SYMBOL] ?? []); if (sectorMap) { const row = rows.find((r) => r.symbol === sectorMap.etf); sectorLeadership = row?.leadership ?? null; } } catch { /* optional */ } // Market regime from available FRED/SPY factors (same soft path as condition). let marketRegime: 'trending-up' | 'trending-down' | 'range-bound' | null = null; let marketRegimeConfidence: number | null = null; try { const regimeInput: Record = {}; const spy = candlesBySym.get(BENCHMARK_SYMBOL) ?? []; if (spy.length >= 50) { const last = spy[spy.length - 1].c; const ago = spy[spy.length - 50].c; if (ago > 0) regimeInput.spyEmaSlope = ((last - ago) / ago) * 100; } try { const vixEntry = await ctx.cache.get('yfinance:quote:^VIX'); if (vixEntry?.value?.price != null) regimeInput.vix = vixEntry.value.price; } catch { /* ignore */ } const classification = classifyRegime(regimeInput); marketRegime = classification.regime; marketRegimeConfidence = classification.confidence; } catch { /* ignore */ } const peers = peerSymbols.slice(0, 8).map((p) => { const ret = returnsBundle(toPts(candlesBySym.get(p) ?? [])); return { symbol: p, name: null as string | null, returns: ret, rsVsMarket1M: relativeTo(ret.oneMonth, marketReturns.oneMonth), }; }); // Optional peer names from symbol meta cache. for (const p of peers) { try { const pe = await ctx.cache.get(`yfinance:symbol:${p.symbol}`); if (pe.value?.name) p.name = pe.value.name; } catch { /* ignore */ } } return buildTickerContext({ symbol, name, sector: sectorName, industry, tickerKind, symbolReturns, marketReturns, marketRegime, marketRegimeConfidence, sectorEtf: sectorMap?.etf ?? null, sectorLabel: sectorMap?.label ?? sectorName, sectorReturns, sectorLeadership, themeEtf: themeMap?.etf ?? null, themeLabel: themeMap?.label ?? null, themeReturns, peers, classificationNote: biz.classificationNote, vendorSector: biz.vendorSector, vendorIndustry: biz.vendorIndustry, }); }), candles: publicProcedure .input(z.object({ symbol: z.string().min(1), timeframe: z.enum(['1d', '1wk', '1mo']).default('1d') })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); const key = `yfinance:candles:${symbol}:${input.timeframe}`; const entry = await ctx.cache.get(key); return { symbol, timeframe: input.timeframe, candles: (entry.value ?? []), isStale: entry.isStale }; }), indicators: publicProcedure .input(z.object({ symbol: z.string().min(1), timeframe: z.enum(['1d', '1wk', '1mo']).default('1d'), periods: z.object({ ema: z.array(z.number().int()).default([9, 21, 50, 200]), rsi: z.number().int().default(14), relvol: z.number().int().default(20), }).default(() => ({ ema: [9, 21, 50, 200], rsi: 14, relvol: 20 })), })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); const key = `yfinance:candles:${symbol}:${input.timeframe}`; const entry = await ctx.cache.get(key); const candles = entry.value ?? []; const periods = input.periods ?? { ema: [9, 21, 50, 200], rsi: 14, relvol: 20 }; const closes = candles.map((c) => c.c); const volumes = candles.map((c) => c.v); const emaObj: Record = {}; for (const p of periods.ema) { emaObj[String(p)] = emaFromCandles(candles, 'adjClose', p); } const macdResult = macdFn(closes); return { ema: emaObj, rsi: rsiFn(closes, periods.rsi), relvol: relativeVolume(volumes, periods.relvol), macd: macdResult, }; }), truckSales: publicProcedure.query(async ({ ctx }) => { const row = ctx.db.prepare('SELECT fred_api_key_enc FROM x_credentials WHERE id=?').get('singleton') as { fred_api_key_enc?: string | null } | undefined; let apiKey = ''; if (row?.fred_api_key_enc) { const { default: encrypt } = await import('../lib/crypto.ts'); try { apiKey = encrypt.decrypt(row.fred_api_key_enc); } catch { /* key corrupt */ } } if (!apiKey) { return { observations: [], seriesTitle: 'Heavy Trucks Sold, Monthly', units: '', configured: false, error: 'FRED API key not configured' }; } try { const { FredAdapterImpl, FRED_SERIES } = await import('../macro/FredAdapter.ts'); const adapter = new FredAdapterImpl(apiKey); const entry = await adapter.series(FRED_SERIES.HEAVY_TRUCK_SALES); const series = entry.value as { seriesId: string; title: string; units: string; observations: Array<{ date: string; value: number }> }; return { observations: series.observations, seriesTitle: series.title, units: series.units, configured: true }; } catch (e) { return { observations: [], seriesTitle: 'Heavy Trucks Sold, Monthly', units: '', configured: true, error: (e as Error).message }; } }), manufacturingPmi: publicProcedure.query(async ({ ctx }) => { const row = ctx.db.prepare('SELECT fred_api_key_enc FROM x_credentials WHERE id=?').get('singleton') as { fred_api_key_enc?: string | null } | undefined; let apiKey = ''; if (row?.fred_api_key_enc) { const { default: encrypt } = await import('../lib/crypto.ts'); try { apiKey = encrypt.decrypt(row.fred_api_key_enc); } catch { /* key corrupt */ } } if (!apiKey) { return { observations: [], seriesTitle: 'Industrial Production: Manufacturing', units: '', configured: false, error: 'FRED API key not configured' }; } try { const { FredAdapterImpl, FRED_SERIES } = await import('../macro/FredAdapter.ts'); const adapter = new FredAdapterImpl(apiKey); const entry = await adapter.series(FRED_SERIES.MANUFACTURING_ACTIVITY); const series = entry.value as { seriesId: string; title: string; units: string; observations: Array<{ date: string; value: number }> }; return { observations: series.observations, seriesTitle: series.title, units: series.units, configured: true }; } catch (e) { return { observations: [], seriesTitle: 'Industrial Production: Manufacturing', units: '', configured: true, error: (e as Error).message }; } }), /** * Market Condition strip — regime + risk flags. * FRED series are queue-warmed (fred schedule, fred_macro tier) — never fetched * live on this request path. SPY/VIX from yfinance cache only. * Educational snapshot only (ADR-0007). */ condition: publicProcedure.query(async ({ ctx }) => { const { classifyRegime } = await import('../macro/MacroRegime.ts'); const { totalReturnPct } = await import('../analysis/marketRotationRs.ts'); const { FRED_SERIES } = await import('../macro/FredAdapter.ts'); const factors: Record = { gdpGrowth: null, cpi: null, unemployment: null, treasury10Y: null, treasury2Y: null, curve10y2y: null, vix: null, spy1M: null, spyEmaSlope: null, consumerSentiment: null, }; type FredObs = Array<{ date: string; value: number }>; /** * FRED observations: kv_cache only (queue warms it). If cold/stale, queue a * background warm-up and return null — never open a live FRED call here. */ async function fredObs(seriesId: string): Promise { const cacheKey = `fred:series:${seriesId}`; try { const row = ctx.db.prepare('SELECT value, observed_at FROM kv_cache WHERE key=?').get(cacheKey) as | { value: string; observed_at: string } | undefined; if (row?.value) { const age = Date.now() - Date.parse(row.observed_at); const parsed = JSON.parse(row.value) as { observations?: FredObs }; const obs = parsed.observations; if (obs?.length && Number.isFinite(age) && age < 24 * 60 * 60_000) { return obs; } } } catch { /* miss */ } // Cold or stale: queue warm-up for the scheduler (no live fetch on request path). try { await ctx.queue.queue(cacheKey); } catch { /* ignore */ } return null; } function lastValue(obs: FredObs | null): number | null { if (!obs?.length) return null; const v = obs[obs.length - 1].value; return Number.isFinite(v) ? v : null; } /** YoY % from monthly index series (needs ~13 observations). */ function yoyPct(obs: FredObs | null): number | null { if (!obs || obs.length < 13) return lastValue(obs); const last = obs[obs.length - 1].value; const yearAgo = obs[obs.length - 13].value; if (!yearAgo || yearAgo === 0) return null; return ((last - yearAgo) / yearAgo) * 100; } // Parallel FRED pulls (rates, labor, inflation, consumer sentiment). const [unrateObs, gs10Obs, gs2Obs, cpiObs, umcObs] = await Promise.all([ fredObs(FRED_SERIES.UNEMPLOYMENT), fredObs(FRED_SERIES.TREASURY_10Y), fredObs(FRED_SERIES.TREASURY_2Y), fredObs(FRED_SERIES.CPI), fredObs(FRED_SERIES.CONSUMER_SENTIMENT), ]); factors.unemployment = lastValue(unrateObs); factors.treasury10Y = lastValue(gs10Obs); factors.treasury2Y = lastValue(gs2Obs); factors.cpi = yoyPct(cpiObs); // classifier expects inflation %, not CPI index level factors.consumerSentiment = lastValue(umcObs); // UMCSENT: University of Michigan Consumer Sentiment Index (index level, not YoY) if (factors.treasury10Y !== null && factors.treasury2Y !== null) { factors.curve10y2y = factors.treasury10Y - factors.treasury2Y; } // SPY trend + VIX from cache/queue only (ADR-0009: no live Yahoo on request path). let spyCandles: PriceCandle[] = []; try { try { await ctx.cache.ensureInDemand('SPY', 'etf'); } catch { /* ignore */ } try { await ctx.cache.ensureInDemand('^VIX', 'index'); } catch { /* ignore */ } const spyEntry = await ctx.cache.get('yfinance:candles:SPY:1d'); spyCandles = (spyEntry?.value ?? []) as PriceCandle[]; factors.spy1M = totalReturnPct(spyCandles, 30 * 86_400_000); if (spyCandles.length >= 60) { const last = spyCandles[spyCandles.length - 1].c; const ago = spyCandles[spyCandles.length - 50].c; if (ago > 0) factors.spyEmaSlope = ((last - ago) / ago) * 100; } } catch { /* ignore */ } try { const vixEntry = await ctx.cache.get('yfinance:quote:^VIX'); if (vixEntry?.value?.price != null) factors.vix = vixEntry.value.price; else { const vixC = await ctx.cache.get('yfinance:candles:^VIX:1d'); const arr = vixC?.value as PriceCandle[] | undefined; if (arr?.length) factors.vix = arr[arr.length - 1].c; } // Queue VIX quote if still cold — never open a live Yahoo call here (shared cool-down). if (factors.vix == null) { try { await ctx.queue.queue('yfinance:quote:^VIX'); } catch { /* ignore */ } } } catch { /* ignore */ } // Per-metric context strings (hover tooltips explaining what each value means). function trailingAvg(obs: Array<{value: number}>, n: number): number | null { if (!obs || obs.length < n) return null; const recent = obs.slice(-n); let sum = 0; for (const o of recent) sum += o.value; return sum / n; } const metricCtx: Record = {}; // SPY 1M — current vs trailing ~6mo average return. if (factors.spy1M != null && spyCandles.length >= 126) { const recent = spyCandles.slice(-127, -1); // last ~5 months minus today if (recent.length >= 2) { const firstPrice = recent[0].c; const lastPrice = recent[recent.length - 1].c; if (firstPrice > 0) { const avgReturn = ((lastPrice - firstPrice) / firstPrice) * 100; const label = factors.spy1M > avgReturn ? 'above trend' : 'below trend'; metricCtx.spy1M = label; } } } // VIX — regime-based interpretation (no advice language). if (factors.vix != null) { const v = factors.vix; if (v < 15) metricCtx.vix = 'Low volatility — risk-on regime'; else if (v < 20) metricCtx.vix = 'Normal range'; else if (v < 30) metricCtx.vix = 'Elevated — historically precedes drawdowns'; else metricCtx.vix = 'Stressed — risk-off / crisis territory'; } // Treasury 10Y vs rolling ~3mo average. if (factors.treasury10Y != null && gs10Obs?.length) { const last = factors.treasury10Y; const avg3m = trailingAvg(gs10Obs, Math.min(63, gs10Obs.length)); if (avg3m != null) { const diff = last - avg3m; metricCtx.treasury10Y = diff > 0 ? 'Above 3-month average — headwind for growth if elevated' : 'Below 3-month average'; } else { metricCtx.treasury10Y = 'Headwind for growth if elevated'; } } // Treasury 2Y vs rolling ~3mo average. if (factors.treasury2Y != null && gs2Obs?.length) { const last = factors.treasury2Y; const avg3m = trailingAvg(gs2Obs, Math.min(63, gs2Obs.length)); if (avg3m != null) { const diff = last - avg3m; metricCtx.treasury2Y = diff > 0 ? 'Above 3-month average — tighter monetary conditions' : 'Below 3-month average'; } else { metricCtx.treasury2Y = 'Tight monetary conditions'; } } // Curve sign + magnitude (50bp threshold for inversion signal). if (factors.curve10y2y != null) { const cBp = factors.curve10y2y * 100; if (cBp < -50) metricCtx.curve10y2y = 'Inverted — historically bearish for equities'; else if (cBp < 0) metricCtx.curve10y2y = 'Slightly inverted — watch for steepening'; else if (cBp < 50) metricCtx.curve10y2y = 'Positive spread — normal cycle'; else metricCtx.curve10y2y = 'Steep — recovery or inflation expectations rising'; } // Unemployment vs ~5% recession threshold. if (factors.unemployment != null) { const u = factors.unemployment; if (u < 4.0) metricCtx.unemployment = 'Strong labor market'; else if (u < 5.0) metricCtx.unemployment = 'Stable — below recession threshold of 5%'; else if (u < 6.0) metricCtx.unemployment = 'Elevated — monitor for further rises'; else metricCtx.unemployment = 'High — historically correlates with recessions'; } // Consumer sentiment vs UMCSENT psychological thresholds (80/100). if (factors.consumerSentiment != null) { const s = factors.consumerSentiment; if (s > 100) metricCtx.consumerSentiment = 'Strong confidence'; else if (s > 80) metricCtx.consumerSentiment = 'Moderate sentiment'; else if (s > 60) metricCtx.consumerSentiment = 'Weak — below 80 historically precedes drawdowns'; else metricCtx.consumerSentiment = 'Very low confidence — crisis territory'; } // Compute regime + risk flags using raw factors. const classification = classifyRegime({ gdpGrowth: factors.gdpGrowth ?? undefined, cpi: factors.cpi ?? undefined, unemployment: factors.unemployment ?? undefined, treasury10Y: factors.treasury10Y ?? undefined, vix: factors.vix ?? undefined, spyEmaSlope: factors.spyEmaSlope ?? undefined, }); type RiskFlag = { id: string; label: string; severity: 'info' | 'warning' | 'elevated'; detail: string }; const risks: RiskFlag[] = []; if (factors.curve10y2y !== null && factors.curve10y2y < 0) { risks.push({ id: 'curve_inverted', label: 'Yield curve inverted', severity: 'warning', detail: `10Y−2Y is ${factors.curve10y2y.toFixed(2)} pp. Curve inversions have historically preceded slower growth — a risk flag, not a timer.`, }); } if (factors.vix !== null && factors.vix >= 25) { risks.push({ id: 'vol_elevated', label: 'Elevated volatility', severity: factors.vix >= 30 ? 'elevated' : 'warning', detail: `VIX near ${factors.vix.toFixed(1)}. Higher implied fear often coincides with wider daily ranges; sizing frameworks typically reduce risk fractions in this environment.`, }); } if (factors.spy1M !== null && factors.spy1M <= -8) { risks.push({ id: 'spy_drawdown', label: 'Broad market soft (1M)', severity: 'warning', detail: `SPY about ${factors.spy1M.toFixed(1)}% over the last month. Review drawdown tolerance and thesis integrity before adding risk.`, }); } if (factors.unemployment !== null && factors.unemployment >= 5.5) { risks.push({ id: 'labor_soft', label: 'Labor market softer', severity: 'info', detail: `Unemployment near ${factors.unemployment.toFixed(1)}%. Track whether the trend is rising or stabilizing.`, }); } if (risks.length === 0) { risks.push({ id: 'no_flags', label: 'No elevated flags from available data', severity: 'info', detail: 'Available series do not show an inverted curve, elevated VIX, or a sharp one-month broad-market drawdown. Missing data is not the same as absence of risk.', }); } const regimePlain = classification.regime === 'trending-up' ? 'Trending up' : classification.regime === 'trending-down' ? 'Trending down' : 'Range-bound'; return { regime: classification.regime, regimePlain, confidence: classification.confidence, factors: classification.factors, explanation: classification.explanation, metrics: { spy1M: factors.spy1M, vix: factors.vix, treasury10Y: factors.treasury10Y, treasury2Y: factors.treasury2Y, curve10y2y: factors.curve10y2y, unemployment: factors.unemployment, consumerSentiment: factors.consumerSentiment, context: metricCtx, }, risks, updatedAt: new Date().toISOString(), lesson: 'Regime and risk flags summarize current data. They provide process context — not forecasts or allocation instructions.', }; }), rotationCheckForAlert: publicProcedure.mutation(async ({ ctx }) => { const { MARKET_ROTATION_UNIVERSE, BENCHMARK_SYMBOL, buildSectorRsMap, summarizeRotation, } = await import('../analysis/marketRotationRs.ts'); const symbols = [BENCHMARK_SYMBOL, ...MARKET_ROTATION_UNIVERSE.map((s) => s.symbol)]; for (const sym of symbols) { try { await ctx.cache.ensureInDemand(sym, 'etf'); } catch { /* ignore */ } } const keys = symbols.map((s) => `yfinance:candles:${s}:1d`); const entries = await ctx.cache.getMany(keys); const map: Record = {}; symbols.forEach((s, i) => { map[s] = (entries[i]?.value ?? []) as PriceCandle[]; }); const rows = buildSectorRsMap( MARKET_ROTATION_UNIVERSE, map, map[BENCHMARK_SYMBOL] ?? [], ); const summary = summarizeRotation(rows); const signal = summary.strength; const spread = summary.leadershipSpread; const prev = ctx.db.prepare('SELECT signal, checked_at, signal_since FROM rotation_state WHERE id=?').get('singleton') as { signal: string; checked_at: string | null; signal_since: string | null } | undefined; const now = new Date().toISOString(); const signalSince = prev?.signal === signal ? (prev.signal_since ?? now) : now; ctx.db.prepare( `INSERT INTO rotation_state (id, signal, spread, inflow_avg, outflow_avg, checked_at, signal_since) VALUES ('singleton', ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET signal=excluded.signal, spread=excluded.spread, inflow_avg=excluded.inflow_avg, outflow_avg=excluded.outflow_avg, checked_at=excluded.checked_at, signal_since=excluded.signal_since` ).run(signal, spread, summary.leadingCount, summary.laggingCount, now, signalSince); const alertsCreated: string[] = []; if ((signal === 'moderate' || signal === 'strong') && prev?.signal !== signal) { const { createAlert } = await import('../alerts/AlertEngine.ts'); const userIds = ctx.db.prepare('SELECT id FROM users').all() as Array<{ id: string }>; for (const u of userIds) { const deductionKey = `rotation_incipient:${signal}:${summary.leadingGroup}`; const existing = ctx.db.prepare('SELECT id FROM alert_events WHERE dedup_key=? AND user_id=?').get(deductionKey, u.id); if (existing) continue; const alert = createAlert( crypto.randomUUID(), u.id, 'rotation_incipient' as any, undefined, `Relative leadership may be shifting (${signal}). Leading: ${summary.leadingGroup}. Lagging: ${summary.laggingGroup}. Spread ~${spread.toFixed(1)} pp vs SPY.`, signal, { spread, signal, leadingGroup: summary.leadingGroup, laggingGroup: summary.laggingGroup }, ); ctx.db.prepare( `INSERT INTO alert_events (id, user_id, type, severity, title, description, created_at, acknowledged, dedup_key, payload) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)` ).run(alert.id, u.id, alert.type, alert.severity, alert.title, alert.description, alert.createdAt, alert.dedupKey, JSON.stringify(alert.payload)); alertsCreated.push(u.id); } } return { signal, spread, inflowAvg: summary.leadingCount, outflowAvg: summary.laggingCount, prevSignal: prev?.signal ?? null, alertsCreated: alertsCreated.length, leadingGroup: summary.leadingGroup, laggingGroup: summary.laggingGroup, }; }), /** Relative-strength rotation map (vs SPY). Not absolute-return "flow". */ rotation: publicProcedure.query(async ({ ctx }) => { const { MARKET_ROTATION_UNIVERSE, BENCHMARK_SYMBOL, buildSectorRsMap, summarizeRotation, } = await import('../analysis/marketRotationRs.ts'); const { resolveBusinessContext } = await import('../analysis/tickerContext.ts'); const { listHoldings } = await import('../db/portfolioRepository.ts'); const userId = ctx.userId ?? 'anonymous'; // Load custom symbols from DB const customRows = ctx.db.prepare( 'SELECT symbol, name, grp FROM rotation_custom_symbols WHERE owner_id=? ORDER BY symbol', ).all(userId) as Array<{ symbol: string; name: string | null; grp: string }>; const customDefs: import('../analysis/marketRotationRs.ts').SectorDef[] = customRows.map((r) => ({ symbol: r.symbol, name: r.name ?? r.symbol, group: r.grp, kind: 'custom' as const, })); // Auto-group: ETFs that open portfolio holdings map into (sector + theme). // Many of these already exist in MARKET_ROTATION_UNIVERSE (XLK, XLF, …). We must not // drop them on dedupe — track portfolio linkage separately so the Auto filter works. const holdings = listHoldings(ctx.db, userId); type AutoMeta = { name: string; group: string; holdings: string[] }; const portfolioEtfMeta = new Map(); const addPortfolioEtf = (etf: string, name: string, group: string, holdingSym: string) => { const key = etf.toUpperCase(); const prev = portfolioEtfMeta.get(key); if (prev) { if (!prev.holdings.includes(holdingSym)) prev.holdings.push(holdingSym); return; } portfolioEtfMeta.set(key, { name, group, holdings: [holdingSym] }); }; for (const h of holdings) { const holdingSym = h.symbol.toUpperCase(); const meta = ctx.db.prepare('SELECT sector, industry FROM symbols WHERE symbol=?').get(holdingSym) as | { sector: string | null; industry: string | null } | undefined; // Always resolve — symbol overrides (IREN, CIFR, …) work even without a symbols row. const biz = resolveBusinessContext({ symbol: holdingSym, sector: meta?.sector ?? null, industry: meta?.industry ?? null, description: null, }); if (biz.sectorEtf) { addPortfolioEtf( biz.sectorEtf, biz.sectorLabel ?? biz.sectorEtf, biz.sectorLabel ?? 'Portfolio', holdingSym, ); } if (biz.themeEtf) { addPortfolioEtf( biz.themeEtf, biz.themeLabel ?? biz.themeEtf, biz.themeLabel ?? 'Portfolio theme', holdingSym, ); } } // Portfolio-only ETFs (e.g. IGV) that are not already in the market universe const autoOnlyDefs: import('../analysis/marketRotationRs.ts').SectorDef[] = []; for (const [etf, meta] of portfolioEtfMeta) { const inUniverse = MARKET_ROTATION_UNIVERSE.some((u) => u.symbol === etf) || customDefs.some((c) => c.symbol === etf); if (!inUniverse) { autoOnlyDefs.push({ symbol: etf, name: meta.name, group: meta.group, kind: 'auto', }); } } // Combine and deduplicate (universe first so sector/style kinds stay stable) const allDefs = [...MARKET_ROTATION_UNIVERSE, ...customDefs, ...autoOnlyDefs]; const seen = new Set(); const dedupedDefs = allDefs.filter((d) => { if (seen.has(d.symbol)) return false; seen.add(d.symbol); return true; }); const symbols = [BENCHMARK_SYMBOL, ...dedupedDefs.map((s) => s.symbol)]; for (const sym of symbols) { try { await ctx.cache.ensureInDemand(sym, 'etf'); } catch { /* ignore */ } } const keys = symbols.map((s) => `yfinance:candles:${s}:1d`); const entries = await ctx.cache.getMany(keys); const map: Record = {}; let dataPoints = 0; symbols.forEach((s, i) => { const candles = (entries[i]?.value ?? []) as PriceCandle[]; map[s] = candles; if (candles.length > 0) dataPoints += 1; }); // Load live quotes for oneDay overrides — when available, use intraday // change_pct (current price vs previous close) instead of candle dayChangePct, // which reflects overnight gaps because yfinance 1d candles carry 13:30Z bar // timestamps. Other horizons stay candle-based. const quoteRows = ctx.db.prepare( `SELECT symbol, change_pct FROM quotes WHERE symbol IN (${symbols.map(() => '?').join(',')})`, ).all(...symbols) as Array<{ symbol: string; change_pct: number | null }>; const liveQuotes = new Map(quoteRows.map((r) => [r.symbol, r.change_pct ?? null])); const rows = buildSectorRsMap( dedupedDefs, map, map[BENCHMARK_SYMBOL] ?? [], ); // Override oneDay with live quote change_pct when available (true intraday vs prev close). for (const r of rows) { const qPct = liveQuotes.get(r.symbol); if (qPct != null && Number.isFinite(qPct)) { /* already in percent units from yfinance */ r.abs.oneDay = qPct; const benchPct = liveQuotes.get(BENCHMARK_SYMBOL); r.rs.oneDay = benchPct != null ? qPct - benchPct : qPct; } } const summary = summarizeRotation(rows); const state = ctx.db.prepare('SELECT signal_since FROM rotation_state WHERE id=?').get('singleton') as { signal_since: string | null } | undefined; const signalSince = state?.signal_since ?? null; const daysSince = signalSince ? Math.round((Date.now() - new Date(signalSince).getTime()) / 86_400_000) : null; // Backward-compatible sector cards (group = leadership, not abs-return inflow). // portfolioLinked: true when this ETF is a sector/theme proxy for an open holding. // Auto filter uses portfolioLinked so XLK/XLF/etc. still appear under Auto even though // they also live in the main GICS universe (kind remains sector/thematic/…). const sectors = rows.map((r) => { const port = portfolioEtfMeta.get(r.symbol); // Keep universe display names (Technology, Financials); use portfolio label only for auto-only ETFs. const displayName = r.kind === 'auto' && port?.name ? port.name : r.name; return { symbol: r.symbol, name: displayName, group: r.leadership === 'leading' ? 'inflow' as const : r.leadership === 'lagging' ? 'outflow' as const : 'inflow' as const, // inline treated as neutral; UI will use leadership field subGroup: r.group, kind: r.kind, portfolioLinked: !!port, portfolioHoldings: port?.holdings ?? [], leadership: r.leadership, earlyWatch: r.earlyWatch, rank1M: r.rank1M, rank1W: r.rank1W, relVol: r.relVol, oneWeek: r.abs.oneWeek, oneMonth: r.abs.oneMonth, threeMonth: r.abs.threeMonth, sixMonth: r.abs.sixMonth, oneYear: r.abs.oneYear, oneDay: r.abs.oneDay, rsOneWeek: r.rs.oneWeek, rsOneMonth: r.rs.oneMonth, rsThreeMonth: r.rs.threeMonth, rsSixMonth: r.rs.sixMonth, rsOneYear: r.rs.oneYear, rsOneDay: r.rs.oneDay, }; }); const signal = { strength: summary.strength, spread: summary.leadershipSpread, // Legacy field names kept for RotationSignalAlert; mean stronger/weaker counts. inflowAvg: summary.leadingCount, outflowAvg: summary.laggingCount, leadingSubGroup: summary.leadingGroup, laggingSubGroup: summary.laggingGroup, inflowConfirmation: `${summary.leadingCount}/${rows.length}`, outflowConfirmation: `${summary.laggingCount}/${rows.length}`, earlyWatchCount: summary.earlyWatchCount, daysSince, summary: summary.summary, }; // Persist today's rank snapshot so we can show history later. try { const asOf = new Date().toISOString().slice(0, 10); const ins = ctx.db.prepare( `INSERT OR REPLACE INTO rotation_rank_snapshots (as_of_date, symbol, name, grp, rank_1m, rs_1m, rank_1w, rs_1w, leadership, early_watch, strength) VALUES (?,?,?,?,?,?,?,?,?,?,?)`, ); for (const r of rows) { ins.run( asOf, r.symbol, r.name, r.group, r.rank1M, r.rs.oneMonth, r.rank1W, r.rs.oneWeek, r.leadership, r.earlyWatch ? 1 : 0, summary.strength, ); } } catch { /* table may not exist until migration; ignore */ } // Recent early-watch history (last 14 days). let earlyHistory: Array<{ date: string; symbol: string; name: string; rs1w: number | null }> = []; try { earlyHistory = ctx.db.prepare( `SELECT as_of_date AS date, symbol, name, rs_1w AS rs1w FROM rotation_rank_snapshots WHERE early_watch = 1 AND as_of_date >= date('now', '-14 day') ORDER BY as_of_date DESC, rank_1w ASC LIMIT 40`, ).all() as Array<{ date: string; symbol: string; name: string; rs1w: number | null }>; } catch { /* ignore */ } return { sectors, signal, benchmark: BENCHMARK_SYMBOL, coveredSymbols: dataPoints, totalSymbols: symbols.length, earlyHistory, fetchedAt: new Date().toISOString(), lesson: 'Relative strength compares each ETF to the broad market (SPY). Outperformance means higher return than SPY over the window — not measured ETF share creation or redemption flows.', }; }), /** Historical seasonality for SPY (and optional symbol) + simple calendar. */ seasonality: publicProcedure .input(z.object({ symbol: z.string().min(1).max(12).optional() }).optional()) .query(async ({ ctx, input }) => { const { buildSeasonalitySnapshot, upcomingSimpleEvents } = await import('../analysis/seasonality.ts'); const symbol = (input?.symbol ?? 'SPY').toUpperCase(); try { await ctx.cache.ensureInDemand(symbol, symbol === 'SPY' ? 'etf' : 'equity'); } catch { /* ignore */ } const entry = await ctx.cache.get(`yfinance:candles:${symbol}:1d`); const candles = (entry?.value ?? []) as PriceCandle[]; const snapshot = buildSeasonalitySnapshot( symbol, candles.map((c) => ({ ts: c.ts, c: c.c })), ); return { ...snapshot, events: upcomingSimpleEvents(), hasEnoughHistory: candles.length >= 250, lesson: 'Historical monthly averages are context only. They do not forecast the current year.', }; }), /** * Top holdings for a sector/theme ETF (e.g. SMH, XLK). * Resolution order: fresh kv_cache → live Yahoo → stale cache → static fallback. * Day quotes from local `quotes` only (no Yahoo chart/quote fanout under rate limits). */ sectorHoldings: publicProcedure .input(z.object({ etfSymbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const { staticHoldingsFor } = await import('../analysis/etfHoldingsFallback.ts'); const symbol = input.etfSymbol.toUpperCase(); const cacheKey = `etf:topHoldings:${symbol}`; const FRESH_MS = 24 * 60 * 60_000; type HoldingRow = { symbol: string; holdingName: string; holdingPercent: number }; function readCachedComposition(): { rows: HoldingRow[]; observedAt: string } | null { try { const row = ctx.db.prepare('SELECT value, observed_at FROM kv_cache WHERE key=?').get(cacheKey) as | { value: string; observed_at: string } | undefined; if (!row?.value) return null; const parsed = JSON.parse(row.value) as { holdings?: HoldingRow[] }; const rows = (parsed.holdings ?? []) .filter((h) => h?.symbol) .map((h) => ({ symbol: String(h.symbol).toUpperCase(), holdingName: h.holdingName ?? String(h.symbol), holdingPercent: Number(h.holdingPercent) || 0, })); if (!rows.length) return null; return { rows, observedAt: row.observed_at }; } catch { return null; } } function writeCachedComposition(rows: HoldingRow[]): void { try { ctx.db.prepare( 'INSERT OR REPLACE INTO kv_cache (key, value, observed_at) VALUES (?,?,?)', ).run(cacheKey, JSON.stringify({ symbol, holdings: rows }), new Date().toISOString()); } catch { /* best-effort; SQLite lock under concurrent write is fine */ } } function fromStatic(): HoldingRow[] { return staticHoldingsFor(symbol).map((h) => ({ symbol: h.symbol.toUpperCase(), holdingName: h.holdingName, holdingPercent: h.holdingPercent, })); } let holdings: HoldingRow[] = []; let source: 'live' | 'cache' | 'static' | 'empty' = 'empty'; const cached = readCachedComposition(); const cacheAgeMs = cached ? Date.now() - Date.parse(cached.observedAt) : Infinity; const cacheFresh = Number.isFinite(cacheAgeMs) && cacheAgeMs < FRESH_MS; if (cacheFresh && cached) { holdings = cached.rows; source = 'cache'; } else if (cached) { // Stale composition is fine for a peek panel; background queue refreshes. holdings = cached.rows; source = 'cache'; if (!ctx.queue.isSourceCoolingDown('yfinance')) { try { await ctx.queue.queue(`yfinance:topHoldings:${symbol}`); } catch { /* ignore */ } } } else { // Cold path: static fallback first (authoritative when present). const fallback = fromStatic(); if (fallback.length > 0) { holdings = fallback; source = 'static'; writeCachedComposition(fallback); } // Queue background upgrade — never live Yahoo on the request path (ADR-0009). if (!ctx.queue.isSourceCoolingDown('yfinance')) { try { await ctx.queue.queue(`yfinance:topHoldings:${symbol}`); } catch { /* ignore */ } } } if (holdings.length === 0) { return { symbol, holdings: [], source: 'empty' as const }; } // Cap at top 15 for the peek panel (matches UI) and for quote fan-in. const top = holdings.slice(0, 15); const symbols = top.map((h) => h.symbol.toUpperCase()); // Local quotes first — never open live Yahoo on the request path (ADR-0009). const quoteMap = new Map(); try { const placeholders = symbols.map(() => '?').join(','); const rows = ctx.db.prepare( `SELECT symbol, price, change, change_pct FROM quotes WHERE symbol IN (${placeholders})`, ).all(...symbols) as Array<{ symbol: string; price: number | null; change: number | null; change_pct: number | null; }>; for (const r of rows) { quoteMap.set(r.symbol.toUpperCase(), { price: r.price, change: r.change, changePercent: r.change_pct, }); } } catch { /* local quotes optional */ } // Candle close fallback when quote row is missing (still local, no vendor call). const candleClose = new Map(); try { for (const sym of symbols) { if (quoteMap.get(sym)?.price != null) continue; const row = ctx.db.prepare( `SELECT c FROM price_candles WHERE symbol=? AND timeframe='1d' ORDER BY ts DESC LIMIT 1`, ).get(sym) as { c: number } | undefined; if (row && Number.isFinite(row.c)) candleClose.set(sym, row.c); } } catch { /* ignore */ } // Background-queue missing US-style tickers so the next open has live marks. // Skip exotic listings (dots / long suffixes) to avoid poisoning the queue. if (!ctx.queue.isSourceCoolingDown('yfinance')) { for (const sym of symbols) { if (quoteMap.has(sym) && quoteMap.get(sym)!.price != null) continue; if (sym.includes('.') || sym.length > 5) continue; try { await ctx.cache.ensureInDemand(sym, 'equity'); } catch { /* ignore */ } } } const result = top.map((h) => { const sym = h.symbol.toUpperCase(); const q = quoteMap.get(sym); const price = q?.price ?? candleClose.get(sym) ?? null; return { symbol: sym, name: h.holdingName, weight: h.holdingPercent, price, change: q?.change ?? null, changePercent: q?.changePercent ?? null, volume: null as number | null, marketCap: null as number | null, returns1W: null as number | null, returns1M: null as number | null, returns3M: null as number | null, returns6M: null as number | null, returns1Y: null as number | null, }; }); return { symbol, holdings: result, source }; }), /** Add a custom ETF to the user's rotation tracking. */ addCustomEtf: publicProcedure .input(z.object({ symbol: z.string().toUpperCase(), name: z.string().optional(), group: z.string().optional() })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const existing = ctx.db.prepare('SELECT id FROM rotation_custom_symbols WHERE owner_id=? AND symbol=?').get(userId, input.symbol) as { id: string } | undefined; if (existing) return { added: false }; ctx.db.prepare( 'INSERT INTO rotation_custom_symbols (id, owner_id, symbol, name, grp, created_at) VALUES (?,?,?,?,?,?)' ).run(crypto.randomUUID(), userId, input.symbol, input.name ?? input.symbol, input.group ?? 'Custom', new Date().toISOString()); try { await ctx.cache.ensureInDemand(input.symbol, 'etf'); } catch { /* ignore */ } return { added: true }; }), /** Remove a custom ETF from the user's rotation tracking. */ removeCustomEtf: publicProcedure .input(z.object({ symbol: z.string().toUpperCase() })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const r = ctx.db.prepare('DELETE FROM rotation_custom_symbols WHERE owner_id=? AND symbol=?').run(userId, input.symbol); return { removed: r.changes > 0 }; }), /** List the user's custom rotation ETFs. */ listCustomEtfs: publicProcedure.query(async ({ ctx }) => { const userId = ctx.userId ?? 'anonymous'; const rows = ctx.db.prepare('SELECT symbol, name, grp, created_at FROM rotation_custom_symbols WHERE owner_id=? ORDER BY symbol').all(userId) as Array<{ symbol: string; name: string | null; grp: string; created_at: string }>; return rows.map((r) => ({ ...r, name: r.name ?? r.symbol })); }), }); function parseCandlesFromChart(raw: Record): PriceCandle[] { const quotes = raw.quotes; if (!Array.isArray(quotes)) return []; const out: PriceCandle[] = []; for (const q of quotes as Array | null>) { if (!q) continue; const close = typeof q.adjclose === 'number' && Number.isFinite(q.adjclose) ? q.adjclose : (typeof q.close === 'number' && Number.isFinite(q.close) ? q.close : null); if (close === null) continue; out.push({ ts: q.date instanceof Date ? q.date.toISOString() : (typeof q.date === 'string' ? q.date : ''), o: typeof q.open === 'number' ? q.open : 0, h: typeof q.high === 'number' ? q.high : 0, l: typeof q.low === 'number' ? q.low : 0, c: close, v: typeof q.volume === 'number' ? q.volume : 0, }); } return out; } // --------------------------------------------------------------------------- // admin.* (Slice 25) — operator tooling, auth-gated to is_admin=1. // --------------------------------------------------------------------------- const adminRouter = router({ usersList: adminProcedure.query(({ ctx }) => listUsers(ctx.db)), setUserModules: adminProcedure .input(z.object({ userId: z.string().uuid(), modules: z.array(z.string()) })) .mutation(({ ctx, input }) => { return setUserModules(ctx.db, ctx.userId, input.userId, input.modules); }), disableUser: adminProcedure .input(z.object({ userId: z.string().uuid() })) .mutation(({ ctx, input }) => { try { return disableUser(ctx.db, ctx.userId, input.userId); } catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to disable user.' }); } }), enableUser: adminProcedure .input(z.object({ userId: z.string().uuid() })) .mutation(({ ctx, input }) => { try { return enableUser(ctx.db, ctx.userId, input.userId); } catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to enable user.' }); } }), deleteUser: adminProcedure .input(z.object({ userId: z.string().uuid() })) .mutation(({ ctx, input }) => { try { return deleteUser(ctx.db, ctx.userId, input.userId); } catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to delete user.' }); } }), resetPassword: adminProcedure .input(z.object({ email: z.string().email(), tempPassword: z.string().min(8) })) .mutation(({ ctx, input }) => { try { return resetPassword(ctx.db, ctx.userId, input.email, hashPassword(input.tempPassword)); } catch (e) { if (e instanceof NotOwnerError) throw new TRPCError({ code: 'FORBIDDEN', message: e.message }); throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); } }), gdprExport: adminProcedure .input(z.object({ userId: z.string().uuid() })) .mutation(({ ctx, input }) => { try { return gdprExport(ctx.db, ctx.userId, input.userId); } catch (e) { if (e instanceof NotOwnerError) throw new TRPCError({ code: 'FORBIDDEN', message: e.message }); throw e; } }), queueHealth: adminProcedure.query(({ ctx }) => queueHealth(ctx.db)), /** Alert-system observability: producer cadence + source freshness. */ alertStatus: adminProcedure.query(({ ctx }) => alertStatus(ctx.db)), resetBackoff: adminProcedure .input(z.object({ sourceKind: z.string().regex(/^[a-z0-9_]+$/i) })) .mutation(({ ctx, input }) => resetQueueBackoff(ctx.db, ctx.userId, input.sourceKind)), userSessions: adminProcedure .input(z.object({ userId: z.string().uuid() })) .query(({ ctx, input }) => listUserSessions(ctx.db, input.userId)), auditLog: adminProcedure .input(z.object({ limit: z.number().int().min(1).max(200).default(50), offset: z.number().int().min(0).default(0), actor: z.string().optional(), action: z.string().optional(), })) .query(({ ctx, input }) => listAuditLog(ctx.db, { limit: input.limit, offset: input.offset, actor: input.actor ?? null, action: input.action ?? null })), queueSecFetch: adminProcedure .input(z.object({ symbol: z.string().min(1).max(10) })) .mutation(({ ctx, input }) => { try { queueSecFetch(ctx.db, input.symbol); return { ok: true }; } catch (e) { throw new TRPCError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Failed to queue fetch.' }); } }), /** Run GEX/VEX integrity audit (and optional as-of replay) for a symbol. */ dealerMapIntegrity: adminProcedure .input(z.object({ symbol: z.string().min(1).max(12), /** When set, rebuild surface from chains with ts ≤ asOf (backtest). */ asOf: z.string().min(10).optional(), recompute: z.boolean().optional().default(true), })) .mutation(async ({ ctx, input }) => { const symbol = input.symbol.trim().toUpperCase(); const { auditDealerMap, integritySummary } = await import('../analysis/dealerMapIntegrity.ts'); const { recomputeDealerMapFromCacheDetailed, writeDealerMapDataQuality, } = await import('../analysis/dealerMapService.ts'); const { replayDealerMapAt } = await import('../analysis/dealerMapReplay.ts'); if (input.asOf) { const replay = replayDealerMapAt(ctx.db, symbol, input.asOf); writeDealerMapDataQuality(ctx.db, symbol, replay.integrity); recordAudit(ctx.db, ctx.userId ?? 'admin', 'dealer.integrity', symbol, { mode: 'replay', asOf: input.asOf, status: replay.integrity.status, ok: replay.ok, }); return { mode: 'replay' as const, symbol, asOf: input.asOf, ok: replay.ok, summary: replay.summary, integrity: replay.integrity, integrityText: integritySummary(replay.integrity), king: replay.map?.levels.king ?? null, spot: replay.spot, spotSource: replay.spotSource, surfaceRowCount: replay.surfaceRowCount, snapshotCompare: replay.snapshotCompare, }; } let map = null as Awaited>['map']; let integrity = auditDealerMap(null, null); let keptPriorMap = false; let message: string | undefined; if (input.recompute) { const detail = recomputeDealerMapFromCacheDetailed(ctx.db, symbol, { write: true }); map = detail.map; integrity = detail.integrity ?? auditDealerMap(detail.map, detail.surface); keptPriorMap = detail.keptPriorMap; message = detail.message; if (detail.integrity) writeDealerMapDataQuality(ctx.db, symbol, detail.integrity); } recordAudit(ctx.db, ctx.userId ?? 'admin', 'dealer.integrity', symbol, { mode: 'live', status: integrity.status, keptPriorMap, }); return { mode: 'live' as const, symbol, ok: integrity.status !== 'incomplete', summary: message ?? integritySummary(integrity), integrity, integrityText: integritySummary(integrity), king: map?.levels.king ?? null, spot: map?.spot ?? null, keptPriorMap, cells: map?.cells.length ?? 0, quality: map?.quality ?? null, }; }), queueStatus: adminProcedure.query(({ ctx }) => ctx.queue.health()), /** Force-clear a vendor cool-down (operator recovery after rate-limit thrash). */ clearSourceCooldown: adminProcedure .input(z.object({ sourceKind: z.string().min(1) })) .mutation(({ ctx, input }) => { ctx.queue.clearSourceCooldownManual(input.sourceKind); return { ok: true, sourceKind: input.sourceKind }; }), queuePause: adminProcedure.mutation(({ ctx }) => { ctx.queue.setPaused(true); return { paused: true }; }), queueResume: adminProcedure.mutation(({ ctx }) => { ctx.queue.setPaused(false); return { paused: false }; }), queueLogs: adminProcedure .input(z.object({ key: z.string().min(1), limit: z.number().int().min(1).max(100).optional().default(10) })) .query(({ ctx, input }) => ctx.queue.getErrorLog(input.key, input.limit)), queueRetryJob: adminProcedure .input(z.object({ key: z.string().min(1) })) .mutation(({ ctx, input }) => { ctx.queue.retryJob(input.key); return { ok: true }; }), queueRetrySource: adminProcedure .input(z.object({ sourceKind: z.string().min(1) })) .mutation(({ ctx, input }) => { const cleared = ctx.queue.retrySource(input.sourceKind); return { cleared }; }), queueClearDone: adminProcedure .input(z.object({ olderThanHours: z.number().min(1).max(336).optional().default(24) })) .mutation(({ ctx, input }) => { const cleared = ctx.queue.clearDone(input.olderThanHours * 3600000); return { cleared }; }), queueSchedules: adminProcedure.query(({ ctx }) => ctx.queue.listSchedules()), queueSetSchedule: adminProcedure .input(z.object({ sourceKind: z.string().min(1), intervalMs: z.number().int().min(60000).max(604800000) })) .mutation(({ ctx, input }) => { ctx.queue.setSchedule(input.sourceKind, input.intervalMs); return { ok: true }; }), queueDeleteSchedule: adminProcedure .input(z.object({ sourceKind: z.string().min(1) })) .mutation(({ ctx, input }) => { ctx.queue.deleteSchedule(input.sourceKind); return { ok: true }; }), queueLint: adminProcedure .input(z.object({ symbol: z.string().min(1).max(10), kind: z.enum(['sec-lint-holders', 'sec-lint-insiders']) })) .mutation(async ({ ctx, input }) => { const { lintInstitutionalHolders, lintInsiderTransactions, lintSCFilings } = await import('../services/secDataFetcher.ts'); let result: LintResult; if (input.kind === 'sec-lint-holders') { result = await lintInstitutionalHolders(ctx.db, input.symbol); await lintSCFilings(ctx.db, input.symbol).catch(() => {}); } else { result = await lintInsiderTransactions(ctx.db, input.symbol); } // Also enqueue so the weekly schedule picks it up too. try { ctx.queue.queue(`${input.kind}:${input.kind === 'sec-lint-holders' ? 'holders' : 'insiders'}:${input.symbol}`); } catch { /* non-fatal */ } return result; }), dataQualityList: adminProcedure .input(z.object({ symbol: z.string().min(1).max(20).optional(), kind: z.enum(['institution_filings', 'insider_transactions']).nullish() })) .query(({ ctx, input }) => { let sql = 'SELECT symbol, kind, last_checked_at, stored_count, discovered_count, missing_count, stale, status, detail FROM data_quality WHERE 1=1'; const params: SQLInputValue[] = []; if (input.symbol) { sql += ' AND symbol = ?'; params.push(input.symbol); } if (input.kind) { sql += ' AND kind = ?'; params.push(input.kind); } sql += ' ORDER BY last_checked_at DESC LIMIT 200'; return ctx.db.prepare(sql).all(...params) as Array>; }), queueLintAll: adminProcedure .input(z.object({ kind: z.enum(['sec-lint-holders', 'sec-lint-insiders']) })) .mutation(async ({ ctx, input }) => { const symbols = (ctx.db.prepare('SELECT symbol FROM symbol_demand WHERE in_demand=1 ORDER BY symbol').all() as Array<{ symbol: string }>).map((r) => r.symbol); const kindPath = input.kind === 'sec-lint-holders' ? 'holders' : 'insiders'; for (const sym of symbols) { try { await ctx.queue.queue(`${input.kind}:${kindPath}:${sym}`); } catch { /* ignore */ } } return { total: symbols.length, enqueued: symbols.length }; }), // --------------------------------------------------------------------------- // Admin: X cookie credentials & tracked accounts management. // Credentials are stored AES-256-GCM encrypted at rest; never returned to clients. // --------------------------------------------------------------------------- xCredentialsStatus: adminProcedure.query(({ ctx }) => { const row = ctx.db.prepare('SELECT healthy, last_error, updated_at FROM x_credentials WHERE id=?').get('singleton') as { healthy?: number; last_error?: string | null; updated_at?: string } | undefined; if (!row) return { configured: false, healthy: 'degraded' as const, lastError: null, updatedAt: null }; return { configured: true, healthy: row.healthy === 1 ? ('healthy' as const) : ('degraded' as const), lastError: row.last_error ?? null, updatedAt: row.updated_at ?? null, }; }), xCredentialsSet: adminProcedure .input(z.object({ ct0: z.string().min(1), auth_token: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const encrypt = (await import('../lib/crypto.ts')).default; try { const now = new Date().toISOString(); const ct0_enc = encrypt(input.ct0); const auth_token_enc = encrypt(input.auth_token); ctx.db.prepare( `INSERT INTO x_credentials (id, ct0_enc, auth_token_enc, healthy, last_error, updated_at) VALUES ('singleton', ?, ?, 1, NULL, ?) ON CONFLICT(id) DO UPDATE SET ct0_enc=excluded.ct0_enc, auth_token_enc=excluded.auth_token_enc, healthy=1, last_error=NULL, updated_at=excluded.updated_at` ).run(ct0_enc, auth_token_enc, now); ctx.xAdapter?.setCookies({ ct0: input.ct0, auth_token: input.auth_token }); ctx.cache.del('x:cashtag:*'); return { ok: true }; } catch (e) { throw new TRPCError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Failed to set X credentials.' }); } }), // --------------------------------------------------------------------------- // Admin: FRED API key management. // --------------------------------------------------------------------------- fredKeyStatus: adminProcedure.query(({ ctx }) => { const row = ctx.db.prepare('SELECT fred_api_key_enc FROM x_credentials WHERE id=?').get('singleton') as { fred_api_key_enc?: string | null } | undefined; return { configured: !!(row?.fred_api_key_enc) }; }), fredKeySet: adminProcedure .input(z.object({ apiKey: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const encrypt = (await import('../lib/crypto.ts')).default; try { const now = new Date().toISOString(); const fred_api_key_enc = encrypt(input.apiKey); ctx.db.prepare( `INSERT INTO x_credentials (id, fred_api_key_enc, updated_at) VALUES ('singleton', ?, ?) ON CONFLICT(id) DO UPDATE SET fred_api_key_enc=excluded.fred_api_key_enc, updated_at=excluded.updated_at` ).run(fred_api_key_enc, now); return { ok: true }; } catch (e) { throw new TRPCError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Failed to set FRED API key.' }); } }), xAccountsList: adminProcedure.query(({ ctx }) => { const rows = ctx.db.prepare("SELECT id, symbol, handle, COALESCE(label, '') AS label, created_at FROM x_accounts ORDER BY symbol, handle").all(); return (rows ?? []) as Array<{id: string; symbol: string; handle: string; label: string; created_at: string}>; }), xAccountAdd: adminProcedure .input(z.object({ symbol: z.string().min(1).max(20), handle: z.string().min(1).regex(/^[a-zA-Z0-9_]+$/), label: z.string().optional() })) .mutation(async ({ ctx, input }) => { try { const id = randomUUID(); const now = new Date().toISOString(); ctx.db.prepare('INSERT INTO x_accounts (id, symbol, handle, label, created_at) VALUES (?, ?, ?, ?, ?)').run(id, input.symbol.toUpperCase(), input.handle.toLowerCase(), input.label ?? null, now); // Fire-and-forget 30-day backfill. setTimeout(() => { import('../x/backfill.ts').then(({ backfillAccount }) => backfillAccount(ctx.db, input.symbol.toUpperCase(), input.handle.toLowerCase()) ).catch((e) => console.error('[xAccountAdd] backfill failed:', e)); }, 0); return { id }; } catch (e) { if ((e as Error).message?.includes('UNIQUE constraint')) throw new TRPCError({ code: 'CONFLICT', message: `Account @${input.handle} already tracked for $${input.symbol}` }); throw new TRPCError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Failed to add account.' }); } }), xAccountRemove: adminProcedure .input(z.object({ id: z.string().uuid() })) .mutation(({ ctx, input }) => { const info = ctx.db.prepare('DELETE FROM x_accounts WHERE id=?').run(input.id); if (info.changes === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Account not found.' }); return { ok: true }; }), xPrune: adminProcedure .input(z.object({ olderThanDays: z.number().int().min(1).max(365).optional().default(30), })) .mutation(({ ctx, input }) => { const cutoff = new Date(Date.now() - input.olderThanDays * 24 * 60 * 60 * 1000).toISOString(); const info = ctx.db.prepare('DELETE FROM x_cookie_posts WHERE posted_at < ?').run(cutoff); return { deleted: info.changes }; }), pendingUsers: adminProcedure.query(({ ctx }) => { return ctx.db.prepare('SELECT id, email, complexity, created_at FROM users WHERE status=? ORDER BY created_at ASC').all('pending_approval') as Array<{ id: string; email: string; complexity: string; created_at: string }>; }), approveUser: adminProcedure .input(z.object({ userId: z.string().uuid() })) .mutation(({ ctx, input }) => { const info = ctx.db.prepare('UPDATE users SET status=? WHERE id=? AND status=?').run('active', input.userId, 'pending_approval'); if (info.changes === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'No pending user found with that ID.' }); return { ok: true }; }), rejectUser: adminProcedure .input(z.object({ userId: z.string().uuid() })) .mutation(({ ctx, input }) => { const info = ctx.db.prepare('UPDATE users SET status=? WHERE id=? AND status=?').run('rejected', input.userId, 'pending_approval'); if (info.changes === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'No pending user found with that ID.' }); return { ok: true }; }), // Restart the app servers from the admin console. Responds immediately, then // restarts the backend (and best-effort the Next frontend) after a short beat. serverRestart: adminProcedure .input(z.object({ target: z.enum(['backend', 'frontend', 'all']).optional().default('all') })) .mutation(({ input }) => { const status = restartServers((input.target ?? 'all') as RestartTarget); return { ok: true, ...status }; }), /** Get SMTP config (admin-only). */ smtpConfig: adminProcedure.query(async ({ ctx }) => { const { readSmtpConfig } = await import('../services/emailAlertService.ts'); return readSmtpConfig(ctx.db) ?? { enabled: false }; }), /** Update SMTP config (admin-only). */ smtpConfigUpdate: adminProcedure .input(z.object({ host: z.string().optional(), port: z.number().int().optional(), secure: z.boolean().optional(), user: z.string().optional().nullable(), pass: z.string().optional().nullable(), fromName: z.string().optional(), fromEmail: z.string().optional(), enabled: z.boolean().optional(), })) .mutation(({ ctx, input }) => { const config = ctx.db.prepare('SELECT * FROM smtp_config WHERE id = ?').get('singleton') as Record | undefined; const now = new Date().toISOString(); if (!config) { ctx.db.prepare( `INSERT INTO smtp_config (id, host, port, secure, user, pass_enc, from_name, from_email, enabled, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ).run('singleton', input.host ?? 'smtp.mail.me.com', input.port ?? 587, input.secure ? 1 : 0, input.user ?? null, input.pass ?? null, input.fromName ?? 'Investor Flow', input.fromEmail ?? '', input.enabled ? 1 : 0, now); } else { const updates: string[] = []; const params: (string | number | null)[] = []; if (input.host !== undefined) { updates.push('host = ?'); params.push(input.host); } if (input.port !== undefined) { updates.push('port = ?'); params.push(input.port); } if (input.secure !== undefined) { updates.push('secure = ?'); params.push(input.secure ? 1 : 0); } if (input.user !== undefined) { updates.push('user = ?'); params.push(input.user); } if (input.pass !== undefined) { updates.push('pass_enc = ?'); params.push(input.pass); } if (input.fromName !== undefined) { updates.push('from_name = ?'); params.push(input.fromName); } if (input.fromEmail !== undefined) { updates.push('from_email = ?'); params.push(input.fromEmail); } if (input.enabled !== undefined) { updates.push('enabled = ?'); params.push(input.enabled ? 1 : 0); } updates.push('updated_at = ?'); params.push(now); params.push('singleton'); ctx.db.prepare(`UPDATE smtp_config SET ${updates.join(', ')} WHERE id = ?`).run(...params); } return { ok: true }; }), /** Test SMTP config by sending a test email to the admin. */ smtpConfigTest: adminProcedure.mutation(async ({ ctx }) => { const { readSmtpConfig, sendAlertEmail } = await import('../services/emailAlertService.ts'); const config = readSmtpConfig(ctx.db); if (!config || !config.enabled) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SMTP not configured or disabled.' }); const testAlert = { id: 'test', userId: ctx.userId as string, type: 'rotation_incipient' as const, severity: 'info' as const, title: 'Test alert from Investor Flow', description: 'This is a test email to verify SMTP configuration.', symbol: undefined, createdAt: new Date().toISOString(), acknowledged: false, dedupKey: 'test:' + Date.now(), payload: {}, }; const sent = await sendAlertEmail(ctx.db, testAlert); if (!sent) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to send test email.' }); return { ok: true }; }), /** Get FINRA config (admin-only). */ finraConfig: adminProcedure.query(({ ctx }) => ({ baseUrl: getFinraBaseUrl(ctx.db), })), /** Update FINRA download URL (admin-only). */ finraConfigUpdate: adminProcedure .input(z.object({ baseUrl: z.string().url() })) .mutation(({ ctx, input }) => setFinraBaseUrl(ctx.db, input.baseUrl)), /** Trigger FINRA 30-day backfill. Skips existing dates, rate-limited 500ms. */ finraBackfill: adminProcedure .input(z.object({ days: z.number().int().min(1).max(200).optional().default(30) })) .mutation(async ({ ctx, input }) => { const { backfillFinra } = await import('../services/FinraIngestService.ts'); const result = await backfillFinra(ctx.db, input.days, getFinraBaseUrl(ctx.db)); recordAudit(ctx.db, ctx.userId!, 'finra.backfill', null, { days: input.days, filesStored: result.filesStored, errors: result.errors.length }); return result; }), /** Trigger FINRA bi-monthly short interest backfill (~24 months default). */ finraSiBackfill: adminProcedure .input(z.object({ months: z.number().int().min(1).max(36).optional().default(24) })) .mutation(async ({ ctx, input }) => { const { backfillFinraSi } = await import('../services/FinraIngestService.ts'); const result = await backfillFinraSi(ctx.db, input.months); recordAudit(ctx.db, ctx.userId!, 'finra-si.backfill', null, { months: input.months, filesStored: result.filesStored, errors: result.errors.length }); return result; }), }); // Slice 8 — Institutional Dashboard Rollup (read-only, no trade actions). const dashboardRouter = router({ rollup: publicProcedure .input(z.object({})) .query(async ({ ctx }) => { const userId = ctx.userId ?? 'anonymous'; // Import lazily to avoid circular deps. const { DashboardRollupEngine } = await import('../analysis/dashboardRollup.ts'); const { InstitutionFlowEngine } = await import('../analysis/institutionFlowEngine.ts'); // Use a no-op EdgarAdapter for the rollup (we query DB directly). const noopEdgar = { sourceKind: 'sec' as const, async fetchOne(_key: string) { throw new Error('not used'); }, async filings_index(_cik: string, _opts?: any) { return { value: [], ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }, async company_facts(_cik: string) { return { value: {}, ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }, async filer_cik_meta(_cik: string) { return { value: {}, ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }, async full_text_search(_q: string) { return { value: [], ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }, async form13f_holdings(_cik: string, _accession: string) { return { value: { holdings: [] }, ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }, async form4_tx(_cik: string, _accession: string) { return { value: { transactions: [] }, ttlClass: 'daily_permanent' as const, provenance: { fetchedAt: new Date().toISOString(), sourceKind: 'sec' as const } }; }, } as any; const flowEngine = new InstitutionFlowEngine(noopEdgar); const engine = new DashboardRollupEngine(ctx.db, flowEngine); return engine.computeRollup(userId); }), }); // ─── Alerts Router (Slice 17) ──────────────────────────────────────────────── const alertsRouter = router({ /** List alerts for the current user, newest first. */ list: protectedProcedure .input(z.object({ limit: z.number().min(1).max(100).optional().default(50) })) .query(async ({ ctx, input }) => { const userId = ctx.userId as string; const rows = ctx.db.prepare(` SELECT id, user_id, type, severity, title, description, symbol, created_at, acknowledged, dedup_key, payload FROM alert_events WHERE user_id = ? ORDER BY created_at DESC LIMIT ? `).all(userId, input.limit) as Array>; return rows.map((r) => ({ id: r.id, userId: r.user_id, type: r.type, severity: r.severity, title: r.title, description: r.description, symbol: r.symbol ?? undefined, createdAt: r.created_at, acknowledged: r.acknowledged === 1 || r.acknowledged === true, dedupKey: r.dedup_key, payload: typeof r.payload === 'string' ? JSON.parse(r.payload as string) : r.payload, })); }), /** Acknowledge an alert (mark as read). */ acknowledge: protectedProcedure .input(z.object({ alertId: z.string() })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const result = ctx.db.prepare( 'UPDATE alert_events SET acknowledged = 1 WHERE id = ? AND user_id = ?' ).run(input.alertId, userId); if (result.changes === 0) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Alert not found.' }); } return { ok: true }; }), /** Acknowledge all alerts for the current user. */ acknowledgeAll: protectedProcedure .mutation(async ({ ctx }) => { const userId = ctx.userId as string; ctx.db.prepare( 'UPDATE alert_events SET acknowledged = 1 WHERE user_id = ? AND acknowledged = 0' ).run(userId); return { ok: true }; }), /** Get unacknowledged alert count. */ unackedCount: protectedProcedure .query(async ({ ctx }) => { const userId = ctx.userId as string; const row = ctx.db.prepare( 'SELECT COUNT(*) as count FROM alert_events WHERE user_id = ? AND acknowledged = 0' ).get(userId) as { count: number } | undefined; return { count: row?.count ?? 0 }; }), /** Create an alert subscription rule. */ createSubscription: protectedProcedure .input(z.object({ alertType: z.string(), watchlistId: z.string().optional(), symbol: z.string().optional(), params: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const { createAlertSubscription } = await import('../db/alertSubscriptionRepository.ts'); const sub = createAlertSubscription(ctx.db, userId, { alertType: input.alertType, watchlistId: input.watchlistId, symbol: input.symbol, params: input.params, }); return sub; }), /** List alert subscriptions (optionally filtered by symbol). */ listSubscriptions: protectedProcedure .input(z.object({ symbol: z.string().optional() }).optional()) .query(async ({ ctx, input }) => { const userId = ctx.userId as string; const { listAlertSubscriptions } = await import('../db/alertSubscriptionRepository.ts'); return listAlertSubscriptions(ctx.db, userId, input?.symbol); }), /** Update an alert subscription (enable/disable, change params). */ updateSubscription: protectedProcedure .input(z.object({ id: z.string(), enabled: z.boolean().optional(), params: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const { updateAlertSubscription } = await import('../db/alertSubscriptionRepository.ts'); const sub = updateAlertSubscription(ctx.db, userId, input.id, { enabled: input.enabled, params: input.params, }); if (!sub) throw new TRPCError({ code: 'NOT_FOUND', message: 'Subscription not found.' }); return sub; }), /** Delete an alert subscription. */ deleteSubscription: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const { deleteAlertSubscription } = await import('../db/alertSubscriptionRepository.ts'); const deleted = deleteAlertSubscription(ctx.db, userId, input.id); if (!deleted) throw new TRPCError({ code: 'NOT_FOUND', message: 'Subscription not found.' }); return { ok: true }; }), /** Per-type toggle list: which alert types the user receives, plus labels. */ listTypes: protectedProcedure .query(async ({ ctx }) => { const userId = ctx.userId as string; const { listAlertTypeToggles } = await import('../db/alertSubscriptionRepository.ts'); return listAlertTypeToggles(ctx.db, userId); }), /** Toggle a whole alert type on/off for the current user (authoritative). */ toggleType: protectedProcedure .input(z.object({ alertType: z.string(), enabled: z.boolean() })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const { toggleAlertType } = await import('../db/alertSubscriptionRepository.ts'); try { return toggleAlertType(ctx.db, userId, input.alertType, input.enabled); } catch (e) { throw new TRPCError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Failed to toggle alert type.', }); } }), }); // ─── Institutional Flow Router (Slice 7 / M4 + M5) ──────────────────────── interface InstitutionalFlowResult { cusip: string; name: string; prevShares: number; currShares: number; delta: number; classification: string; holderClass?: string; } interface InsiderStreamEvent { reporter: string; relationship: string; securityTitle: string; transactionDate: string; transactionCode: string; shares: number; price: number; netDirection: string; is10b5Plan?: boolean; planDetails?: string; } interface InsiderStreamSummary { cik: string; events: InsiderStreamEvent[]; netShares: number; direction: string | null; transactionType: 'Informed' | 'Routine'; } const institutionalRouter = router({ /** Get institutional flow for a symbol (M4 per-symbol view). */ flow: publicProcedure .input(z.object({ symbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); // Query institution_filings for this symbol, grouped by filer_cik const rows = ctx.db.prepare(` SELECT filer_cik, filer_name, symbol, form, shares, value_usd, reported_quarter, filed_at, put_call FROM institution_filings WHERE symbol = ? ORDER BY filed_at DESC `).all(symbol) as Array<{ filer_cik: string; filer_name: string | null; symbol: string; form: string; shares: number | null; value_usd: number | null; reported_quarter: string; filed_at: string; put_call: string | null; }>; if (rows.length === 0) { return { symbol, flow: [], quarters: [], filings: [] }; } // Group by filer_cik, then by reported_quarter to compute QoQ flow const byCik = new Map(); for (const r of rows) { const arr = byCik.get(r.filer_cik) ?? []; arr.push(r); byCik.set(r.filer_cik, arr); } const allFlow: Array<{ filerCik: string; filerName: string | null; form: string; prevShares: number; currShares: number; delta: number; classification: string; reportedQuarter: string; }> = []; for (const [cik, cikRows] of byCik) { // One position per reported quarter (latest filed wins) — not filed_at pair only. // Prior bug: only compared the two newest rows by file date, skipped intermediate // quarters, and sometimes spanned non-adjacent periods (false mega-deltas). const byQuarter = new Map(); const sortedByFiled = [...cikRows].sort((a, b) => String(b.filed_at).localeCompare(String(a.filed_at)), ); for (const r of sortedByFiled) { if (!byQuarter.has(r.reported_quarter)) byQuarter.set(r.reported_quarter, r); } const quartersAsc = [...byQuarter.keys()].sort((a, b) => a.localeCompare(b)); for (let i = 1; i < quartersAsc.length; i++) { const prevQ = quartersAsc[i - 1]; const currQ = quartersAsc[i]; const prev = byQuarter.get(prevQ)!; const curr = byQuarter.get(currQ)!; const prevShares = prev.shares ?? 0; const currShares = curr.shares ?? 0; const delta = currShares - prevShares; if (delta === 0) continue; let classification: string; if (prevShares === 0 && currShares > 0) classification = 'new position'; else if (prevShares > 0 && currShares === 0) classification = 'exited'; else if (delta > 0) classification = 'added to position'; else classification = 'reduced position'; allFlow.push({ filerCik: cik, filerName: curr.filer_name, form: curr.form, prevShares, currShares, delta, classification, reportedQuarter: currQ, }); } } allFlow.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); // Collect unique quarters const quarters = [...new Set(rows.map(r => r.reported_quarter))].sort().reverse(); // Return individual filing rows for month-granularity grouping const filings = rows.map(r => ({ filerCik: r.filer_cik, filerName: r.filer_name, shares: r.shares ?? 0, valueUsd: r.value_usd ?? 0, reportedQuarter: r.reported_quarter, filedAt: r.filed_at, form: r.form, putCall: r.put_call ?? '', })); // Compute institutional ownership % across all tracked funds. const ownership = computeOwnershipPercentages(ctx.db, symbol); return { symbol, flow: allFlow, quarters, filings, ownership }; }), /** Get insider activity stream for a symbol (M5 quarterly price strip). */ insiderStream: publicProcedure .input(z.object({ symbol: z.string().min(1), limit: z.number().int().min(1).max(500).optional().default(200), })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); // Go back 5 years so the monthly chart has real depth. const fiveYearsAgo = new Date(); fiveYearsAgo.setFullYear(fiveYearsAgo.getFullYear() - 5); const since = fiveYearsAgo.toISOString().slice(0, 10); const transactions = ctx.db.prepare(` SELECT form4_id, symbol, insider_name, insider_role, tx_date, tx_code, tx_type, shares, price, is_10b5_1, classification, filed_at FROM insider_transactions WHERE symbol = ? AND tx_date >= ? ORDER BY tx_date DESC LIMIT ? `).all(symbol, since, input.limit) as Array<{ form4_id: string; symbol: string; insider_name: string; insider_role: string | null; tx_date: string; tx_code: string; tx_type: string; shares: number | null; price: number | null; is_10b5_1: number; classification: string; filed_at: string; }>; let netShares = 0; const events = transactions.map((tx) => { const shares = tx.shares ?? 0; const direction = tx.tx_type === 'buy' || tx.tx_code === 'P' ? 1 : -1; netShares += shares * direction; return { reporter: tx.insider_name, relationship: tx.insider_role, transactionDate: tx.tx_date, transactionCode: tx.tx_code, transactionType: tx.tx_type, shares, price: tx.price, is10b5: tx.is_10b5_1 === 1, classification: tx.classification, filedAt: tx.filed_at, }; }); return { symbol, events, netShares, count: events.length, }; }), /** Aggregate institutional ownership by month for overlay charts. */ ownershipHistory: publicProcedure .input(z.object({ symbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); const rows = ctx.db.prepare(` SELECT substr(filed_at, 1, 7) as month, SUM(shares) as total_shares, COUNT(DISTINCT filer_cik) as num_filers FROM institution_filings WHERE symbol = ? AND shares IS NOT NULL GROUP BY month ORDER BY month ASC `).all(symbol) as Array<{ month: string; total_shares: number; num_filers: number }>; return { symbol, months: rows }; }), /** Per-filer buy events computed from QoQ 13F delta. */ buyEvents: publicProcedure .input(z.object({ symbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); const rows = ctx.db.prepare(` SELECT filer_cik, filer_name, reported_quarter, shares, value_usd FROM institution_filings WHERE symbol = ? AND shares IS NOT NULL AND value_usd IS NOT NULL ORDER BY filer_cik, reported_quarter ASC `).all(symbol) as Array<{ filer_cik: string; filer_name: string | null; reported_quarter: string; shares: number; value_usd: number; }>; const byFiler = new Map(); for (const r of rows) { const arr = byFiler.get(r.filer_cik) ?? []; arr.push(r); byFiler.set(r.filer_cik, arr); } const events: Array<{ filerName: string | null; filerCik: string; sharesAdded: number; estimatedPrice: number; date: string; }> = []; for (const [, filerRows] of byFiler) { for (let i = 1; i < filerRows.length; i++) { const prev = filerRows[i - 1]; const curr = filerRows[i]; const delta = curr.shares - prev.shares; if (delta > 0) { const price = curr.value_usd > 0 ? curr.value_usd / curr.shares : 0; const parts = curr.reported_quarter.split('-Q'); const y = parseInt(parts[0]); const q = parseInt(parts[1]); const month = q * 3; // Q1=3, Q2=6, Q3=9, Q4=12 const lastDay = new Date(y, month, 0).getDate(); const date = `${y}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; events.push({ filerName: curr.filer_name, filerCik: curr.filer_cik, sharesAdded: delta, estimatedPrice: price, date }); } } } return { symbol, events }; }), /** Analyst ratings (upgrades/downgrades + consensus). Cache first; live only when Yahoo not cooling down. */ analystRatings: publicProcedure .input(z.object({ symbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); const { fetchAndStoreAnalystRatings, getAnalystRatings } = await import('../services/analystRatingsService.ts'); const cached = getAnalystRatings(ctx.db, symbol); if (cached && !cached.stale) return { symbol, ratings: cached.ratings, consensus: cached.consensus }; // Respect shared yfinance cool-down — never stampede Edge from this panel. if (ctx.queue.isSourceCoolingDown('yfinance')) { if (cached) return { symbol, ratings: cached.ratings, consensus: cached.consensus, stale: true }; return { symbol, ratings: [], consensus: null, error: 'Yahoo cooling down — ratings will refresh when rate limit clears' }; } const result = await fetchAndStoreAnalystRatings(ctx.db, symbol, { onRateLimit: () => { try { ctx.queue.applySourceCooldown('yfinance', 'analystRatings 429'); } catch { /* ignore */ } }, }); if ('error' in result) { if (cached) return { symbol, ratings: cached.ratings, consensus: cached.consensus, stale: true }; return { symbol, ratings: [], consensus: null, error: result.error }; } return { symbol, ratings: result.ratings, consensus: result.consensus }; }), /** Short interest: FINRA bi-monthly (outstanding, regulatory gold) + * FINRA daily volume (weekly activity chart) + NASDAQ/Yahoo (cross-check). * All three outstanding sources compared for discrepancy (apples-to-apples). */ shortInterest: publicProcedure .input(z.object({ symbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); const [yfEntry, ndqEntry, frVolEntry, frSiEntry] = await Promise.all([ ctx.cache.get>(`yfinance:shortinterest:${symbol}`), ctx.cache.get>(`nasdaq:nasdaqShortinterest:${symbol}`), ctx.cache.get>(`finra-bulk:finraShortinterest:${symbol}`), ctx.cache.get>(`finra-si:finraSiBiweekly:${symbol}`), ]); let yf = yfEntry.value; let ndq = ndqEntry.value; let frVol = frVolEntry.value; let frSi = frSiEntry.value; // Background refresh via shared queue only — no live Yahoo/Nasdaq on request path. if (!yf) { try { await ctx.queue.queue(`yfinance:shortinterest:${symbol}`); } catch { /* ignore */ } } if (!ndq) { try { await ctx.queue.queue(`nasdaq:nasdaqShortinterest:${symbol}`); } catch { /* ignore */ } } if (!frVol) { try { const row = ctx.db.prepare('SELECT settlement_date,exchange,short_volume,short_exempt,total_volume,days_to_cover FROM finra_short_interest WHERE symbol=? ORDER BY settlement_date DESC LIMIT 1').get(symbol) as Record | undefined; if (row) { const v = { shortVolume: row.short_volume, shortExempt: row.short_exempt, totalVolume: row.total_volume, settlementDate: row.settlement_date, exchange: row.exchange, daysToCover: row.days_to_cover, }; await ctx.cache.set(`finra-bulk:finraShortinterest:${symbol}`, v, 'short_interest', { fetchedAt: new Date().toISOString(), sourceKind: 'finra-bulk', rawSourceId: symbol, }); frVol = v as Record; } } catch { /* ignore */ } } if (!frSi) { try { const row = ctx.db.prepare('SELECT current_short_position,previous_short_position,avg_daily_volume,days_to_cover,change_percent,settlement_date,ingested_at FROM finra_short_interest_biweekly WHERE symbol=? ORDER BY settlement_date DESC LIMIT 1').get(symbol) as Record | undefined; if (row) { const v = { currentShortPosition: row.current_short_position, previousShortPosition: row.previous_short_position, avgDailyVolume: row.avg_daily_volume, daysToCover: row.days_to_cover, changePercent: row.change_percent, settlementDate: row.settlement_date, }; await ctx.cache.set(`finra-si:finraSiBiweekly:${symbol}`, v, 'short_interest', { fetchedAt: new Date().toISOString(), sourceKind: 'finra-si', rawSourceId: symbol, }); frSi = v as Record; } } catch { /* ignore */ } } const comp = [ {s:'finra',v:(frSi?.currentShortPosition as number|undefined)??null}, {s:'nasdaq',v:ndq?.sharesShort as number|null}, {s:'yfinance',v:yf?.sharesShort as number|null} ].filter(x=>x.v!=null&&x.v>0); let discPct: number|null=null, discBetw: string|null=null; if (comp.length>=2){ const s=[...comp].sort((a,b)=>(b.v??0)-(a.v??0)); const hi=s[0].v!, lo=s[s.length-1].v!; const p=Math.abs(hi-lo)/lo; if(p>0.10){ discPct=Math.round(p*100); discBetw=`${s[0].s} vs ${s[s.length-1].s}`; } } const sharesShort = frSi?.currentShortPosition ?? ndq?.sharesShort ?? yf?.sharesShort ?? null; const daysToCover = frSi?.daysToCover ?? ndq?.daysToCover ?? yf?.shortRatio ?? null; const settlementDate = frSi?.settlementDate ?? ndq?.settlementDate ?? yf?.dateShortInterest ?? null; const history = ndq?.history ?? null; const biRows = ctx.db.prepare('SELECT settlement_date, current_short_position, previous_short_position, days_to_cover, change_percent FROM finra_short_interest_biweekly WHERE symbol=? AND settlement_date >= date(\'now\',\'-24 months\') ORDER BY settlement_date ASC').all(symbol) as Array<{settlement_date:string;current_short_position:number;previous_short_position:number|null;days_to_cover:number|null;change_percent:number|null}>; const finraBiweeklyHistory = biRows.map(r=>({settlementDate:r.settlement_date,currentShortPosition:r.current_short_position,previousShortPosition:r.previous_short_position,daysToCover:r.days_to_cover,changePercent:r.change_percent})); const volRows = ctx.db.prepare('SELECT settlement_date,short_volume,total_volume FROM finra_short_interest WHERE symbol=? AND settlement_date >= date(\'now\',\'-180 days\') ORDER BY settlement_date ASC').all(symbol) as Array<{settlement_date:string;short_volume:number;total_volume:number}>; const wm=new Map(); for (const r of volRows){ const d=new Date(r.settlement_date+'T00:00:00Z'); const mon=d.getUTCDay()===0?6:d.getUTCDay()-1; const we=new Date(d); we.setUTCDate(d.getUTCDate()+mon); const k=we.toISOString().slice(0,10); const e=wm.get(k)||{sv:0,tv:0,e:k}; e.sv+=r.short_volume; e.tv+=r.total_volume; wm.set(k,e); } const finraWeekly=[...wm.values()].sort((a,b)=>a.e.localeCompare(b.e)).map(w=>({weekEnding:w.e,shortVolume:w.sv,totalVolume:w.tv,shortPct:w.tv>0?Math.round(w.sv/w.tv*10000)/100:0})); return { symbol, sharesShort, sharesShortPriorMonth:yf?.sharesShortPriorMonth??null, sharesPercentSharesOut:yf?.sharesPercentSharesOut??null, shortRatio:yf?.shortRatio??null, shortPercentOfFloat:yf?.shortPercentOfFloat??null, dateShortInterest:yf?.dateShortInterest??null, floatShares:yf?.floatShares??null, sharesOutstanding:yf?.sharesOutstanding??null, daysToCover, settlementDate, history, finraBiweeklyHistory, finraWeekly, discrepancyPct:discPct, discrepancyBetween:discBetw, sources:{yfinance:{available:!!yf,stale:yfEntry.isStale},nasdaq:{available:!!ndq,stale:ndqEntry.isStale},finra:{available:!!frSi,stale:frSiEntry.isStale}}, stale:yfEntry.isStale&&ndqEntry.isStale&&frSiEntry.isStale }; }), }); // ─── EDGAR / SEC Filings Router (Slice 6 / M6) ──────────────────────────── interface EdgarFiling { form: string; dateReporter: string; accessionNumber: string; accessionNormalization: string; reportDate: string; reportFile: string; primaryDocument: string; } interface FilerMeta { cik: string; name: string | null; sic: string | null; } interface Form13fHoldingsResult { holdings: Array<{ cusip: string; issuerName: string; value: number; sshPrnamt: number; }>; total: number; accession: string; } interface Form4TxResult { transactions: Array<{ reporter: string; relationship: string; securityTitle: string; transactionDate: string; transactionCode: string; shares: number; price: number; }>; accession: string; } const edgarRouter = router({ /** Recent filings for a CIK, filterable by form type + date range. */ filings_index: publicProcedure .input(z.object({ cik: z.string().min(1), formTypes: z.array(z.string()).optional(), dateRange: z.object({ from: z.string().optional(), to: z.string().optional() }).optional(), })) .query(async ({ ctx, input }) => { const edgar = new EdgarAdapter(); try { const result = await edgar.filings_index(input.cik, { formTypes: input.formTypes, dateRange: input.dateRange, }); return { filings: result.value as EdgarFiling[], cik: input.cik }; } catch (e) { throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message }); } }), /** Company facts (XBRL-derived financials) for a CIK. */ company_facts: publicProcedure .input(z.object({ cik: z.string().min(1) })) .query(async ({ ctx, input }) => { const edgar = new EdgarAdapter(); try { const result = await edgar.company_facts(input.cik); return { facts: result.value as Record, cik: input.cik }; } catch (e) { throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message }); } }), /** Filer CIK/SIC metadata (name + SIC). */ filer_cik_meta: publicProcedure .input(z.object({ cik: z.string().min(1) })) .query(async ({ ctx, input }) => { const edgar = new EdgarAdapter(); try { const result = await edgar.filer_cik_meta(input.cik); return { meta: result.value as FilerMeta, cik: input.cik }; } catch (e) { throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message }); } }), /** Full-text search across EDGAR filings. */ full_text_search: publicProcedure .input(z.object({ q: z.string().min(1) })) .query(async ({ ctx, input }) => { const edgar = new EdgarAdapter(); try { const result = await edgar.full_text_search(input.q); return { filings: result.value as Array> }; } catch (e) { throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message }); } }), /** 13F-HR holdings for a CIK + accession (server-side paginated). */ form13f_holdings: publicProcedure .input(z.object({ cik: z.string().min(1), accession: z.string().min(1), limit: z.number().int().positive().optional(), offset: z.number().int().min(0).optional(), })) .query(async ({ ctx, input }) => { const edgar = new EdgarAdapter(); try { const result = await edgar.form13f_holdings(input.cik, input.accession, { limit: input.limit, offset: input.offset, }); const v = result.value as Form13fHoldingsResult; return { holdings: v.holdings, total: v.total, accession: v.accession }; } catch (e) { throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message }); } }), /** Form 4 insider transactions for a CIK + accession. */ form4_tx: publicProcedure .input(z.object({ cik: z.string().min(1), accession: z.string().min(1) })) .query(async ({ ctx, input }) => { const edgar = new EdgarAdapter(); try { const result = await edgar.form4_tx(input.cik, input.accession); return { transactions: result.value as Form4TxResult }; } catch (e) { throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message }); } }), }); // ─── Watchlist Router (Slice 10) ───────────────────────────────────────────── const watchlistRouter = router({ /** List all symbols across all watchlists for the user. */ list: publicProcedure .query(async ({ ctx }) => { const userId = ctx.userId ?? 'anonymous'; const { listSymbols } = await import('../db/watchlistRepository.ts'); return listSymbols(ctx.db, userId); }), /** List symbols for a specific watchlist. */ listByWatchlist: publicProcedure .input(z.object({ name: z.string().min(1).max(100) })) .query(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { listSymbolsByWatchlist } = await import('../db/watchlistRepository.ts'); return listSymbolsByWatchlist(ctx.db, userId, input.name); }), /** List watchlist metadata (name, symbol count, sort order). */ listWatchlists: publicProcedure .query(async ({ ctx }) => { const userId = ctx.userId ?? 'anonymous'; const { listWatchlists } = await import('../db/watchlistRepository.ts'); return listWatchlists(ctx.db, userId); }), /** Create a new named watchlist. */ create: publicProcedure .input(z.object({ name: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { createWatchlist } = await import('../db/watchlistRepository.ts'); return createWatchlist(ctx.db, userId, input.name); }), /** Delete a watchlist by name. */ delete: publicProcedure .input(z.object({ name: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { deleteWatchlist } = await import('../db/watchlistRepository.ts'); return { deleted: deleteWatchlist(ctx.db, userId, input.name) }; }), /** Rename a watchlist. */ rename: publicProcedure .input(z.object({ oldName: z.string().min(1), newName: z.string().min(1).max(100) })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { renameWatchlist } = await import('../db/watchlistRepository.ts'); return { renamed: renameWatchlist(ctx.db, userId, input.oldName, input.newName) }; }), /** Reorder watchlists (bulk update sort_order). */ reorder: publicProcedure .input(z.object({ orders: z.array(z.object({ id: z.string(), sort_order: z.number().int() })) })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { reorderWatchlists } = await import('../db/watchlistRepository.ts'); reorderWatchlists(ctx.db, userId, input.orders); return { ok: true }; }), /** Add a symbol to a watchlist (defaults to 'default' watchlist). */ addSymbol: publicProcedure .input(z.object({ symbol: z.string().toUpperCase(), watchlistName: z.string().optional(), notes: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { addSymbol, materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts'); const added = addSymbol(ctx.db, userId, input.symbol, input.notes, input.watchlistName); if (added) { materializeClassificationWatchlists(ctx.db, userId); try { await ctx.cache.subscribe(input.symbol, 'equity'); } catch { /* ignore */ } try { queueSecFetch(ctx.db, input.symbol); } catch { /* ignore */ } } return { added }; }), /** Remove a symbol from a watchlist (defaults to 'default'). */ removeSymbol: publicProcedure .input(z.object({ symbol: z.string().toUpperCase(), watchlistName: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { removeSymbol, materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts'); const removed = removeSymbol(ctx.db, userId, input.symbol, input.watchlistName); if (removed) { materializeClassificationWatchlists(ctx.db, userId); try { await ctx.cache.unsubscribe(input.symbol); } catch { /* ignore */ } } return { removed }; }), /** Move a symbol from one watchlist to another (both default to 'default'). */ moveSymbol: publicProcedure .input(z.object({ symbol: z.string().toUpperCase(), fromWatchlist: z.string().optional(), toWatchlist: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { moveSymbol } = await import('../db/watchlistRepository.ts'); const moved = moveSymbol(ctx.db, userId, input.symbol, input.fromWatchlist ?? 'default', input.toWatchlist ?? 'default'); return { moved }; }), }); // ─── Portfolio Router (Slice 10) ────────────────────────────────────────────── const portfolioRouter = router({ /** List all open holdings. */ holdings: publicProcedure .query(async ({ ctx }) => { const userId = ctx.userId ?? 'anonymous'; const { listHoldings } = await import('../db/portfolioRepository.ts'); return listHoldings(ctx.db, userId); }), /** Add or accumulate a holding. */ addHolding: publicProcedure .input(z.object({ symbol: z.string().toUpperCase(), shares: z.number().positive(), avgCost: z.number().min(0) })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { addHolding } = await import('../db/portfolioRepository.ts'); const created = addHolding(ctx.db, userId, input.symbol, input.shares, input.avgCost); try { const { materializePortfolioWatchlist } = await import('../db/watchlistRepository.ts'); materializePortfolioWatchlist(ctx.db, userId); } catch { /* ignore */ } if (created) { try { await ctx.cache.subscribe(input.symbol, 'equity'); } catch { /* ignore */ } try { queueSecFetch(ctx.db, input.symbol); } catch { /* ignore */ } } return { created }; }), /** Update an existing holding's shares and/or avg cost. */ updateHolding: publicProcedure .input(z.object({ symbol: z.string().toUpperCase(), shares: z.number().positive().optional(), avgCost: z.number().min(0).optional(), })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { updateHolding } = await import('../db/portfolioRepository.ts'); const updated = updateHolding(ctx.db, userId, input.symbol, { shares: input.shares, avgCost: input.avgCost }); try { const { materializePortfolioWatchlist } = await import('../db/watchlistRepository.ts'); materializePortfolioWatchlist(ctx.db, userId); } catch { /* ignore */ } return { updated }; }), /** Remove (close) a holding. */ removeHolding: publicProcedure .input(z.object({ symbol: z.string().toUpperCase() })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { removeHolding } = await import('../db/portfolioRepository.ts'); const removed = removeHolding(ctx.db, userId, input.symbol); try { const { materializePortfolioWatchlist } = await import('../db/watchlistRepository.ts'); materializePortfolioWatchlist(ctx.db, userId); } catch { /* ignore */ } if (removed) { await ctx.cache.unsubscribe(input.symbol); } return { removed }; }), /** List open option legs (user book, not chain cache). */ optionLegs: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.userId as string; const { listOptionLegs } = await import('../db/portfolioOptionRepository.ts'); return listOptionLegs(ctx.db, userId); }), /** Record a new option leg. */ addOptionLeg: protectedProcedure .input(z.object({ underlying: z.string().min(1), right: z.enum(['call', 'put']), side: z.enum(['long', 'short']), strike: z.number().positive(), expiry: z.string().min(4), contracts: z.number().positive(), premium: z.number().min(0), multiplier: z.number().positive().optional(), role: z.enum(['long_call', 'long_put', 'covered_call', 'cash_secured_put', 'other']).optional(), note: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const { addOptionLeg, listOptionLegs } = await import('../db/portfolioOptionRepository.ts'); const id = addOptionLeg(ctx.db, userId, { underlying: input.underlying, right: input.right, side: input.side, strike: input.strike, expiry: input.expiry, contracts: input.contracts, premium: input.premium, multiplier: input.multiplier, role: input.role, note: input.note ?? null, }); const legs = listOptionLegs(ctx.db, userId); return { id, legs }; }), /** Soft-close an option leg. */ removeOptionLeg: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const { removeOptionLeg } = await import('../db/portfolioOptionRepository.ts'); const removed = removeOptionLeg(ctx.db, userId, input.id); return { removed }; }), }); // ─── Options Router (Slice 15 / M3) ──────────────────────────────────────── const optionsRouter = router({ /** Full options chain for a symbol + optional expiry. */ chain: publicProcedure .input(z.object({ symbol: z.string().min(1), expiry: z.string().optional() })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); const adapter = new OptionsAdapter(); // If no expiry specified, fetch expiry dates and pick nearest let expiry: string | undefined; if (input.expiry) { expiry = input.expiry; } else { try { const dates = await adapter.expiryDates(symbol); if (dates.length === 0) { return { symbol, expiration: null, rows: [] as OptionChainRow[] }; } // Pick the nearest expiry date expiry = dates.sort()[0]; } catch { return { symbol, expiration: null, rows: [] as OptionChainRow[] }; } } try { const chain = await adapter.chain(symbol, expiry); return { symbol, expiration: chain.expiration, rows: chain.rows }; } catch { return { symbol, expiration: expiry ?? null, rows: [] as OptionChainRow[] }; } }), /** Greeks for a specific option (strike + expiry). */ greeks: publicProcedure .input(z.object({ symbol: z.string().min(1), expiry: z.string().optional(), strike: z.number().optional() })) .query(async ({ ctx, input }) => { const symbol = input.symbol.toUpperCase(); const adapter = new OptionsAdapter(); // If no expiry specified, fetch first available let expiry: string | undefined; if (input.expiry) { expiry = input.expiry; } else { try { const dates = await adapter.expiryDates(symbol); if (dates.length === 0) { return { symbol, greeks: null as OptionGreeks | null }; } expiry = dates.sort()[0]; } catch { return { symbol, greeks: null as OptionGreeks | null }; } } try { const key = `yfinance:greeks:${symbol}:${expiry}:${input.strike ?? 0}`; const result = await adapter.fetchOne(key); const row = result.value as OptionChainRow | null; return { symbol, greeks: row?.greeks ?? null, strike: row?.strike ?? input.strike ?? null, right: row?.right ?? null, lastPrice: row?.lastPrice ?? null, impliedVolatility: row?.impliedVolatility ?? null, }; } catch { return { symbol, greeks: null as OptionGreeks | null }; } }), }); // ─── Dealer Flow (GEX/VEX map) — cache-only reads, schedule-on-miss (ADR-0009) ─── const dealerMapRouter = router({ /** Full map + Layer-0 educator. Never hits Yahoo on this path. */ get: protectedProcedure .input(z.object({ symbol: z.string().min(1), forceSchedule: z.boolean().optional(), maxExpiries: z.number().int().min(1).max(8).optional(), /** classic = call+/put−; dealer_inventory = Heatseeker-style sign flip */ convention: z.enum(['classic_call_pos_put_neg', 'dealer_inventory']).optional(), })) .query(async ({ ctx, input }) => { const { getDealerMap } = await import('../analysis/dealerMapService.ts'); return getDealerMap( { db: ctx.db, cache: ctx.cache, queue: ctx.queue }, input.symbol, { forceSchedule: input.forceSchedule, maxExpiries: input.maxExpiries, convention: input.convention, }, ); }), /** Compact levels for Research strip / chips. */ levels: protectedProcedure .input(z.object({ symbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const { getDealerMap } = await import('../analysis/dealerMapService.ts'); const result = await getDealerMap( { db: ctx.db, cache: ctx.cache, queue: ctx.queue }, input.symbol, ); if (!result.map) { return { symbol: input.symbol.toUpperCase(), status: result.status, scheduled: result.scheduled, levels: null as null, spot: null as number | null, delayNote: null as string | null, providerId: result.providerId, }; } return { symbol: result.map.symbol, status: result.status, scheduled: result.scheduled, levels: result.map.levels, spot: result.map.spot, delayNote: result.map.delayNote, providerId: result.map.providerId, regimeAtSpot: result.map.regimeAtSpot, scenario: result.map.scenario, patterns: result.map.patterns.map((p) => p.id), }; }), /** Scenario + Layer-0 educator only. */ scenario: protectedProcedure .input(z.object({ symbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const { getDealerMap } = await import('../analysis/dealerMapService.ts'); const result = await getDealerMap( { db: ctx.db, cache: ctx.cache, queue: ctx.queue }, input.symbol, ); return { symbol: input.symbol.toUpperCase(), status: result.status, scenario: result.map?.scenario ?? 'insufficient_data', regimeAtSpot: result.map?.regimeAtSpot ?? 'mixed', patterns: result.map?.patterns ?? [], educator: result.educator, delayNote: result.map?.delayNote ?? result.message ?? null, providerId: result.providerId, }; }), /** Velocity summary when prior snapshot exists. */ velocity: protectedProcedure .input(z.object({ symbol: z.string().min(1) })) .query(async ({ ctx, input }) => { const { getDealerMap } = await import('../analysis/dealerMapService.ts'); const result = await getDealerMap( { db: ctx.db, cache: ctx.cache, queue: ctx.queue }, input.symbol, ); return { symbol: input.symbol.toUpperCase(), status: result.status, velocity: result.map?.velocity ?? null, message: result.map?.velocity ? null : 'No prior-day snapshot yet - velocity appears after the first daily recompute.', }; }), /** L1 beginner explain via user's OpenAI-compatible endpoint (or L0 fallback). */ explain: protectedProcedure .input(z.object({ symbol: z.string().min(1) })) .mutation(async ({ ctx, input }) => { try { const { getDealerMap } = await import('../analysis/dealerMapService.ts'); const { explainDealerMap } = await import('../analysis/dealerMapExplain.ts'); const result = await getDealerMap( { db: ctx.db, cache: ctx.cache, queue: ctx.queue }, input.symbol, ); if (!result.map) { return { ok: false, source: 'error' as const, headline: 'No map available', sections: { whatMapShows: result.message ?? 'Map not ready yet.', keyLevels: [] as string[], scenarioInPlainEnglish: '', questionsToAsk: [] as string[], caveats: [result.message ?? ''], dataFreshness: '', }, error: result.message, }; } return await explainDealerMap(ctx.db, ctx.userId!, result.map); } catch (e) { // Never surface an uncaught 500 for AI explain - degrade to structured error. console.error('[dealerMap.explain]', e); const msg = e instanceof Error ? e.message : String(e); return { ok: false, source: 'error' as const, headline: 'Interpretation unavailable', sections: { whatMapShows: 'The AI explanation step failed. The map itself is unaffected.', keyLevels: [] as string[], scenarioInPlainEnglish: '', questionsToAsk: [ 'Retry in a moment?', 'Is your OpenAI-compatible endpoint reachable from Settings → Test?', ], caveats: [msg], dataFreshness: '', }, error: msg, }; } }), }); // ─── Dealer Flow Study Desk (educational practice + auto-grade) ──────────── const HIST_MIN_N = 5; /** Mentor structure rates only influence rank when enough decided grades exist. */ const MENTOR_HIST_MIN_N = 20; function studyScorecardForUser(db: Context['db'], userId: string) { const rows = db.prepare( `SELECT hypothesis_id, grade, score FROM dealer_study_setups WHERE owner_id=?`, ).all(userId) as Array<{ hypothesis_id: string; grade: string | null; score: number | null }>; type Agg = { hypothesisId: string; n: number; wins: number; losses: number; expired: number; incomplete: number; scoreSum: number; scoreN: number; }; const map = new Map(); for (const r of rows) { const a = map.get(r.hypothesis_id) ?? { hypothesisId: r.hypothesis_id, n: 0, wins: 0, losses: 0, expired: 0, incomplete: 0, scoreSum: 0, scoreN: 0, }; a.n++; if (r.grade === 'win') a.wins++; else if (r.grade === 'loss') a.losses++; else if (r.grade === 'expired') a.expired++; else a.incomplete++; if (typeof r.score === 'number') { a.scoreSum += r.score; a.scoreN++; } map.set(r.hypothesis_id, a); } return [...map.values()].map((a) => { const decided = a.wins + a.losses; return { hypothesisId: a.hypothesisId, n: a.n, wins: a.wins, losses: a.losses, expired: a.expired, incomplete: a.incomplete, decidedN: decided, winRate: decided > 0 ? a.wins / decided : null, avgScore: a.scoreN > 0 ? a.scoreSum / a.scoreN : null, }; }).sort((x, y) => y.n - x.n); } /** Path-match rates by mentor claim_type (all handles combined for structure type). */ function mentorClaimScorecard(db: Context['db'], userId: string) { const rows = db.prepare( `SELECT claim_type, grade, score FROM mentor_calls WHERE owner_id=? AND status IN ('confirmed','graded')`, ).all(userId) as Array<{ claim_type: string; grade: string | null; score: number | null }>; type Agg = { claimType: string; n: number; wins: number; losses: number; scoreSum: number; scoreN: number; }; const map = new Map(); for (const r of rows) { const a = map.get(r.claim_type) ?? { claimType: r.claim_type, n: 0, wins: 0, losses: 0, scoreSum: 0, scoreN: 0, }; a.n++; if (r.grade === 'win') a.wins++; else if (r.grade === 'loss') a.losses++; if (typeof r.score === 'number') { a.scoreSum += r.score; a.scoreN++; } map.set(r.claim_type, a); } return [...map.values()].map((a) => { const decided = a.wins + a.losses; return { claimType: a.claimType, n: a.n, decidedN: decided, winRate: decided > 0 ? a.wins / decided : null, avgScore: a.scoreN > 0 ? a.scoreSum / a.scoreN : null, }; }); } const dealerStudyRouter = router({ /** Propose study setups from current cache-only map (+ hist / optional mentor rank). */ propose: protectedProcedure .input(z.object({ symbol: z.string().min(1), /** When true, blend mentor structure path-match rates into rank (default false). */ useMentorHistory: z.boolean().optional(), })) .query(async ({ ctx, input }) => { const { getDealerMap } = await import('../analysis/dealerMapService.ts'); const { proposeStudySetups } = await import('../analysis/dealerStudyEngine.ts'); const { HYPOTHESIS_TO_CLAIM } = await import('../analysis/mentorCallExtract.ts'); const useMentor = input.useMentorHistory === true; const result = await getDealerMap( { db: ctx.db, cache: ctx.cache, queue: ctx.queue }, input.symbol, ); if (!result.map) { return { symbol: input.symbol.toUpperCase(), proposals: [] as Array[number] & { histWinRate: number | null; histN: number; histAvgScore: number | null; mentorWinRate: number | null; mentorN: number; mentorClaimType: string | null; rankScore: number; }>, message: result.message ?? 'Map not ready yet.', status: result.status, useMentorHistory: useMentor, }; } const card = studyScorecardForUser(ctx.db, ctx.userId!); const byH = new Map(card.map((c) => [c.hypothesisId, c])); const mentorByClaim = new Map( mentorClaimScorecard(ctx.db, ctx.userId!).map((c) => [c.claimType, c]), ); const proposals = proposeStudySetups(result.map).map((p) => { const h = byH.get(p.hypothesisId); const decidedN = h?.decidedN ?? 0; const histWinRate = decidedN >= HIST_MIN_N ? h!.winRate : null; const histAvgScore = decidedN >= HIST_MIN_N ? h!.avgScore : null; const claimType = HYPOTHESIS_TO_CLAIM[p.hypothesisId] ?? null; const m = claimType ? mentorByClaim.get(claimType) : undefined; const mentorDecided = m?.decidedN ?? 0; // Always surface mentor stats when available; only blend into rank when toggled + N large enough. const mentorWinRate = mentorDecided > 0 ? m!.winRate : null; const mentorN = mentorDecided; const mentorForRank = useMentor && mentorDecided >= MENTOR_HIST_MIN_N ? mentorWinRate : null; let rankScore = p.confidence; if (histWinRate != null && mentorForRank != null) { rankScore = p.confidence * 0.5 + histWinRate * 0.3 + mentorForRank * 0.2; } else if (histWinRate != null) { rankScore = p.confidence * 0.6 + histWinRate * 0.4; } else if (mentorForRank != null) { rankScore = p.confidence * 0.7 + mentorForRank * 0.3; } return { ...p, histWinRate, histN: decidedN, histAvgScore, mentorWinRate, mentorN, mentorClaimType: claimType, rankScore, }; }).sort((a, b) => b.rankScore - a.rankScore); return { symbol: result.map.symbol, proposals, message: null as string | null, status: result.status, useMentorHistory: useMentor, }; }), /** Log a study setup for later auto-grade. */ log: protectedProcedure .input(z.object({ symbol: z.string().min(1), hypothesisId: z.string().min(1), title: z.string().min(1), bias: z.enum(['educational_long', 'educational_short', 'educational_range']), mapReading: z.string(), studyQuestion: z.string(), entry: z.number(), invalidation: z.number(), target: z.number(), horizonDays: z.number().int().min(1).max(30), confidence: z.number().optional(), disclaimer: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const { getDealerMap } = await import('../analysis/dealerMapService.ts'); const result = await getDealerMap( { db: ctx.db, cache: ctx.cache, queue: ctx.queue }, input.symbol, ); const id = randomUUID(); const loggedAt = new Date().toISOString(); const snapshot = result.map ? JSON.stringify({ symbol: result.map.symbol, spot: result.map.spot, asOf: result.map.asOf, regimeAtSpot: result.map.regimeAtSpot, scenario: result.map.scenario, levels: result.map.levels, patterns: result.map.patterns, }) : '{}'; const disclaimer = input.disclaimer ?? 'Educational observation only.'; ctx.db.prepare(` INSERT INTO dealer_study_setups ( id, owner_id, symbol, hypothesis_id, title, bias, map_reading, study_question, entry, invalidation, target, horizon_days, confidence, map_snapshot, status, logged_at, disclaimer ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'open', ?, ?) `).run( id, ctx.userId!, input.symbol.toUpperCase(), input.hypothesisId, input.title, input.bias, input.mapReading, input.studyQuestion, input.entry, input.invalidation, input.target, input.horizonDays, input.confidence ?? null, snapshot, loggedAt, disclaimer, ); // Ensure daily candles are in demand for later grading (ADR-0009). try { await ctx.cache.ensureInDemand(input.symbol.toUpperCase(), 'equity'); } catch { /* optional */ } return { ok: true as const, id, loggedAt }; }), list: protectedProcedure .input(z.object({ symbol: z.string().optional(), limit: z.number().int().min(1).max(100).optional(), }).optional()) .query(async ({ ctx, input }) => { const limit = input?.limit ?? 30; const symbol = input?.symbol?.toUpperCase(); const rows = symbol ? ctx.db.prepare( `SELECT * FROM dealer_study_setups WHERE owner_id=? AND symbol=? ORDER BY logged_at DESC LIMIT ?`, ).all(ctx.userId!, symbol, limit) : ctx.db.prepare( `SELECT * FROM dealer_study_setups WHERE owner_id=? ORDER BY logged_at DESC LIMIT ?`, ).all(ctx.userId!, limit); return rows as Array>; }), grade: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const { gradeStudySetup } = await import('../analysis/dealerStudyEngine.ts'); const row = ctx.db.prepare( `SELECT * FROM dealer_study_setups WHERE id=? AND owner_id=?`, ).get(input.id, ctx.userId!) as { id: string; symbol: string; bias: 'educational_long' | 'educational_short' | 'educational_range'; entry: number; invalidation: number; target: number; horizon_days: number; logged_at: string; } | undefined; if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Study setup not found.' }); const candles = ctx.db.prepare( `SELECT ts,o,h,l,c FROM price_candles WHERE symbol=? AND timeframe='1d' ORDER BY ts ASC`, ).all(row.symbol) as Array<{ ts: string; o: number; h: number; l: number; c: number }>; const result = gradeStudySetup( { bias: row.bias, entry: row.entry, invalidation: row.invalidation, target: row.target, horizonDays: row.horizon_days, loggedAt: row.logged_at, }, candles.map((b) => ({ ts: b.ts, o: b.o, h: b.h, l: b.l, c: b.c })), ); const status = result.grade === 'incomplete' ? 'open' : 'graded'; ctx.db.prepare(` UPDATE dealer_study_setups SET status=?, grade=?, score=?, grade_summary=?, grade_path=?, graded_at=? WHERE id=? AND owner_id=? `).run( status, result.grade, result.score, result.summary, JSON.stringify(result.pathNotes), result.gradedAt, row.id, ctx.userId!, ); return { ok: true as const, ...result, status }; }), gradeDue: protectedProcedure.mutation(async ({ ctx }) => { const { gradeStudySetup } = await import('../analysis/dealerStudyEngine.ts'); const open = ctx.db.prepare( `SELECT * FROM dealer_study_setups WHERE owner_id=? AND (status='open' OR grade='incomplete')`, ).all(ctx.userId!) as Array<{ id: string; symbol: string; bias: 'educational_long' | 'educational_short' | 'educational_range'; entry: number; invalidation: number; target: number; horizon_days: number; logged_at: string; }>; let graded = 0; let incomplete = 0; for (const row of open) { const candles = (ctx.db.prepare( `SELECT ts,o,h,l,c FROM price_candles WHERE symbol=? AND timeframe='1d' ORDER BY ts ASC`, ).all(row.symbol) as Array<{ ts: string; o: number; h: number; l: number; c: number }>); const result = gradeStudySetup( { bias: row.bias, entry: row.entry, invalidation: row.invalidation, target: row.target, horizonDays: row.horizon_days, loggedAt: row.logged_at, }, candles, ); const status = result.grade === 'incomplete' ? 'open' : 'graded'; if (status === 'graded') graded++; else incomplete++; ctx.db.prepare(` UPDATE dealer_study_setups SET status=?, grade=?, score=?, grade_summary=?, grade_path=?, graded_at=? WHERE id=? AND owner_id=? `).run( status, result.grade, result.score, result.summary, JSON.stringify(result.pathNotes), result.gradedAt, row.id, ctx.userId!, ); } return { ok: true as const, considered: open.length, graded, incomplete }; }), scorecard: protectedProcedure.query(async ({ ctx }) => { return studyScorecardForUser(ctx.db, ctx.userId!).map(({ decidedN: _d, ...rest }) => rest); }), /** Copy a study into journal as a planned trade draft (not an order). */ promoteToJournal: protectedProcedure .input(z.object({ studyId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const row = ctx.db.prepare( `SELECT * FROM dealer_study_setups WHERE id=? AND owner_id=?`, ).get(input.studyId, ctx.userId!) as { id: string; symbol: string; hypothesis_id: string; title: string; bias: string; map_reading: string; study_question: string; entry: number; invalidation: number; target: number; horizon_days: number; } | undefined; if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Study setup not found.' }); const tradeId = randomUUID(); const date = new Date().toISOString().slice(0, 10); const targets = JSON.stringify([ { price: row.target, reason: `Study target (${row.hypothesis_id})` }, ]); const justifications = JSON.stringify({ entryWhy: row.map_reading, stopWhy: `Study invalidation for ${row.hypothesis_id}`, targetWhy: row.study_question, studySetupId: row.id, educational: true, note: 'Draft from Study Desk.', }); const env = row.bias === 'educational_range' ? 'ranging' : row.bias === 'educational_long' ? 'trending' : 'transitioning'; ctx.db.prepare(` INSERT INTO trades ( id, owner_id, trade_id, symbol, date, status, environment, tier, confluence_score, risk_pct, position_size, entry_price, stop_loss, targets, justifications, checklist_completed ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0) `).run( tradeId, ctx.userId!, tradeId, row.symbol, date, 'planned', env, 'B', null, null, null, row.entry, row.invalidation, targets, justifications, ); return { ok: true as const, tradeId, message: 'Journal draft created (planned). Review in Plan/Journal - not an order to trade real money.', }; }), /** Owner-scoped CSV of study rows for offline review. */ exportCsv: protectedProcedure .input(z.object({ symbol: z.string().optional(), limit: z.number().int().min(1).max(2000).optional(), }).optional()) .query(async ({ ctx, input }) => { const limit = input?.limit ?? 500; const symbol = input?.symbol?.toUpperCase(); const rows = symbol ? ctx.db.prepare( `SELECT * FROM dealer_study_setups WHERE owner_id=? AND symbol=? ORDER BY logged_at DESC LIMIT ?`, ).all(ctx.userId!, symbol, limit) : ctx.db.prepare( `SELECT * FROM dealer_study_setups WHERE owner_id=? ORDER BY logged_at DESC LIMIT ?`, ).all(ctx.userId!, limit); const cols = [ 'id', 'symbol', 'hypothesis_id', 'title', 'bias', 'entry', 'invalidation', 'target', 'horizon_days', 'confidence', 'status', 'grade', 'score', 'grade_summary', 'logged_at', 'graded_at', ]; const escape = (v: unknown) => { if (v == null) return ''; const s = String(v); if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`; return s; }; const lines = [cols.join(',')]; for (const r of rows as Array>) { lines.push(cols.map((c) => escape(r[c])).join(',')); } return { filename: `dealer-study-${symbol ?? 'all'}-${new Date().toISOString().slice(0, 10)}.csv`, csv: lines.join('\n'), rowCount: rows.length, }; }), }); // ─── Mentor call ledger (local path-match grades; privacy-first) ─────────── const mentorLedgerRouter = router({ /** Scan local harvest raw/ for draft mentor calls (no cloud LLM). */ importFromHarvest: protectedProcedure .input(z.object({ handle: z.string().optional(), maxFiles: z.number().int().min(1).max(500).optional(), }).optional()) .mutation(async ({ ctx, input }) => { const { readdirSync, readFileSync, existsSync, statSync } = await import('node:fs'); const { join } = await import('node:path'); const { extractMentorCallFromMarkdown } = await import('../analysis/mentorCallExtract.ts'); const rawRoot = join(process.cwd(), 'data', 'dealer-flow-raw', 'by-handle'); if (!existsSync(rawRoot)) { return { ok: true as const, imported: 0, skipped: 0, message: 'No harvest folder yet. Run npm run dealer-flow:harvest first.' }; } const handles = input?.handle ? [input.handle.replace(/^@/, '')] : readdirSync(rawRoot).filter((h) => h !== '_template' && !h.startsWith('.')); const maxFiles = input?.maxFiles ?? 200; let imported = 0; let skipped = 0; let filesSeen = 0; const now = new Date().toISOString(); for (const handle of handles) { const dir = join(rawRoot, handle); if (!existsSync(dir) || !statSync(dir).isDirectory()) continue; ctx.db.prepare( `INSERT INTO mentor_sources (handle, enabled, notes, updated_at) VALUES (?,?,?,?) ON CONFLICT(handle) DO UPDATE SET updated_at=excluded.updated_at`, ).run(handle, 1, 'Imported from local harvest', now); const files = readdirSync(dir).filter((f) => f.endsWith('.md')).slice(0, maxFiles); for (const f of files) { if (filesSeen >= maxFiles) break; filesSeen++; const path = join(dir, f); let md = ''; try { md = readFileSync(path, 'utf8'); } catch { skipped++; continue; } const draft = extractMentorCallFromMarkdown(md, { handle, rawPath: path }); if (!draft || !draft.symbol || draft.bias === 'unclear') { skipped++; continue; } const id = randomUUID(); try { const run = ctx.db.prepare(` INSERT INTO mentor_calls ( id, owner_id, handle, post_id, posted_at, symbol, bias, claim_type, entry, invalidation, target, horizon_days, excerpt, extract_confidence, status, logged_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'draft', ?) ON CONFLICT(owner_id, handle, post_id) DO NOTHING `).run( id, ctx.userId!, draft.handle, draft.postId, draft.postedAt, draft.symbol, draft.bias, draft.claimType, draft.entry, draft.invalidation, draft.target, draft.horizonDays, draft.excerpt, draft.extractConfidence, now, ) as { changes?: number }; if ((run.changes ?? 0) > 0) imported++; else skipped++; } catch { skipped++; } } } return { ok: true as const, imported, skipped, filesSeen, message: null as string | null }; }), list: protectedProcedure .input(z.object({ handle: z.string().optional(), status: z.string().optional(), limit: z.number().int().min(1).max(200).optional(), }).optional()) .query(async ({ ctx, input }) => { const limit = input?.limit ?? 50; const handle = input?.handle?.replace(/^@/, ''); const status = input?.status; if (handle && status) { return ctx.db.prepare( `SELECT * FROM mentor_calls WHERE owner_id=? AND handle=? AND status=? ORDER BY logged_at DESC LIMIT ?`, ).all(ctx.userId!, handle, status, limit); } if (handle) { return ctx.db.prepare( `SELECT * FROM mentor_calls WHERE owner_id=? AND handle=? ORDER BY logged_at DESC LIMIT ?`, ).all(ctx.userId!, handle, limit); } return ctx.db.prepare( `SELECT * FROM mentor_calls WHERE owner_id=? ORDER BY logged_at DESC LIMIT ?`, ).all(ctx.userId!, limit); }), /** Confirm draft so it can be graded. */ confirm: protectedProcedure .input(z.object({ id: z.string().min(1), entry: z.number().optional(), invalidation: z.number().optional(), target: z.number().optional(), bias: z.enum(['educational_long', 'educational_short', 'educational_range']).optional(), horizonDays: z.number().int().min(1).max(30).optional(), symbol: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const row = ctx.db.prepare( `SELECT * FROM mentor_calls WHERE id=? AND owner_id=?`, ).get(input.id, ctx.userId!) as Record | undefined; if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Mentor call not found.' }); ctx.db.prepare(` UPDATE mentor_calls SET status='confirmed', entry=COALESCE(?, entry), invalidation=COALESCE(?, invalidation), target=COALESCE(?, target), bias=COALESCE(?, bias), horizon_days=COALESCE(?, horizon_days), symbol=COALESCE(?, symbol) WHERE id=? AND owner_id=? `).run( input.entry ?? null, input.invalidation ?? null, input.target ?? null, input.bias ?? null, input.horizonDays ?? null, input.symbol?.toUpperCase() ?? null, input.id, ctx.userId!, ); return { ok: true as const }; }), discard: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { ctx.db.prepare( `UPDATE mentor_calls SET status='discarded' WHERE id=? AND owner_id=?`, ).run(input.id, ctx.userId!); return { ok: true as const }; }), grade: protectedProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const { gradeStudySetup } = await import('../analysis/dealerStudyEngine.ts'); const row = ctx.db.prepare( `SELECT * FROM mentor_calls WHERE id=? AND owner_id=?`, ).get(input.id, ctx.userId!) as { id: string; symbol: string | null; bias: 'educational_long' | 'educational_short' | 'educational_range'; entry: number; invalidation: number; target: number; horizon_days: number; posted_at: string | null; logged_at: string; status: string; } | undefined; if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Mentor call not found.' }); if (row.status === 'discarded') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Call was discarded.' }); } if (!row.symbol) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Symbol required before grade.' }); } const loggedAt = row.posted_at && row.posted_at.length >= 10 ? (row.posted_at.includes('T') ? row.posted_at : `${row.posted_at}T00:00:00.000Z`) : row.logged_at; try { await ctx.cache.ensureInDemand(row.symbol, 'equity'); } catch { /* optional */ } const candles = ctx.db.prepare( `SELECT ts,o,h,l,c FROM price_candles WHERE symbol=? AND timeframe='1d' ORDER BY ts ASC`, ).all(row.symbol) as Array<{ ts: string; o: number; h: number; l: number; c: number }>; const result = gradeStudySetup( { bias: row.bias, entry: row.entry, invalidation: row.invalidation, target: row.target, horizonDays: row.horizon_days, loggedAt, }, candles, ); const status = result.grade === 'incomplete' ? 'confirmed' : 'graded'; ctx.db.prepare(` UPDATE mentor_calls SET status=?, grade=?, score=?, grade_summary=?, grade_path=?, graded_at=? WHERE id=? AND owner_id=? `).run( status, result.grade, result.score, result.summary, JSON.stringify(result.pathNotes), result.gradedAt, row.id, ctx.userId!, ); return { ok: true as const, ...result, status }; }), gradeDue: protectedProcedure.mutation(async ({ ctx }) => { const { gradeStudySetup } = await import('../analysis/dealerStudyEngine.ts'); const open = ctx.db.prepare( `SELECT * FROM mentor_calls WHERE owner_id=? AND status IN ('confirmed','graded') AND (grade IS NULL OR grade='incomplete')`, ).all(ctx.userId!) as Array<{ id: string; symbol: string | null; bias: 'educational_long' | 'educational_short' | 'educational_range'; entry: number; invalidation: number; target: number; horizon_days: number; posted_at: string | null; logged_at: string; }>; let graded = 0; let incomplete = 0; for (const row of open) { if (!row.symbol) { incomplete++; continue; } const loggedAt = row.posted_at && row.posted_at.length >= 10 ? (row.posted_at.includes('T') ? row.posted_at : `${row.posted_at.slice(0, 10)}T00:00:00.000Z`) : row.logged_at; const candles = ctx.db.prepare( `SELECT ts,o,h,l,c FROM price_candles WHERE symbol=? AND timeframe='1d' ORDER BY ts ASC`, ).all(row.symbol) as Array<{ ts: string; o: number; h: number; l: number; c: number }>; const result = gradeStudySetup( { bias: row.bias, entry: row.entry, invalidation: row.invalidation, target: row.target, horizonDays: row.horizon_days, loggedAt, }, candles, ); const status = result.grade === 'incomplete' ? 'confirmed' : 'graded'; if (status === 'graded') graded++; else incomplete++; ctx.db.prepare(` UPDATE mentor_calls SET status=?, grade=?, score=?, grade_summary=?, grade_path=?, graded_at=? WHERE id=? AND owner_id=? `).run( status, result.grade, result.score, result.summary, JSON.stringify(result.pathNotes), result.gradedAt, row.id, ctx.userId!, ); } return { ok: true as const, considered: open.length, graded, incomplete }; }), scorecard: protectedProcedure .input(z.object({ handle: z.string().optional() }).optional()) .query(async ({ ctx, input }) => { const handle = input?.handle?.replace(/^@/, ''); const rows = handle ? ctx.db.prepare( `SELECT handle, claim_type, grade, score FROM mentor_calls WHERE owner_id=? AND handle=?`, ).all(ctx.userId!, handle) : ctx.db.prepare( `SELECT handle, claim_type, grade, score FROM mentor_calls WHERE owner_id=?`, ).all(ctx.userId!); type Agg = { key: string; handle: string; claimType: string; n: number; wins: number; losses: number; expired: number; incomplete: number; scoreSum: number; scoreN: number; }; const map = new Map(); for (const r of rows as Array<{ handle: string; claim_type: string; grade: string | null; score: number | null }>) { const key = `${r.handle}::${r.claim_type}`; const a = map.get(key) ?? { key, handle: r.handle, claimType: r.claim_type, n: 0, wins: 0, losses: 0, expired: 0, incomplete: 0, scoreSum: 0, scoreN: 0, }; a.n++; if (r.grade === 'win') a.wins++; else if (r.grade === 'loss') a.losses++; else if (r.grade === 'expired') a.expired++; else a.incomplete++; if (typeof r.score === 'number') { a.scoreSum += r.score; a.scoreN++; } map.set(key, a); } return [...map.values()].map((a) => { const decided = a.wins + a.losses; return { handle: a.handle, claimType: a.claimType, n: a.n, wins: a.wins, losses: a.losses, expired: a.expired, incomplete: a.incomplete, winRate: decided > 0 ? a.wins / decided : null, avgScore: a.scoreN > 0 ? a.scoreSum / a.scoreN : null, }; }).sort((x, y) => y.n - x.n); }), }); // ─── Per-user OpenAI-compatible LLM endpoint ─────────────────────────────── const userLlmRouter = router({ status: protectedProcedure.query(async ({ ctx }) => { const { getUserLlmStatus } = await import('../llm/userLlmEndpoint.ts'); return getUserLlmStatus(ctx.db, ctx.userId!); }), upsertEndpoint: protectedProcedure .input(z.object({ baseUrl: z.string().min(1), apiKey: z.string().optional(), model: z.string().optional(), keepExistingKey: z.boolean().optional(), })) .mutation(async ({ ctx, input }) => { const { upsertUserLlmEndpoint } = await import('../llm/userLlmEndpoint.ts'); return upsertUserLlmEndpoint(ctx.db, ctx.userId!, { baseUrl: input.baseUrl, apiKey: input.apiKey, model: input.model, keepExistingKey: input.keepExistingKey ?? true, }); }), clear: protectedProcedure.mutation(async ({ ctx }) => { const { clearUserLlmEndpoint } = await import('../llm/userLlmEndpoint.ts'); clearUserLlmEndpoint(ctx.db, ctx.userId!); return { ok: true as const }; }), test: protectedProcedure .input(z.object({ baseUrl: z.string().min(1).optional(), apiKey: z.string().optional(), model: z.string().optional(), }).optional()) .mutation(async ({ ctx, input }) => { const { loadUserLlmSecret, getUserLlmStatus } = await import('../llm/userLlmEndpoint.ts'); const { testEndpoint } = await import('../llm/openaiCompatible.ts'); const saved = loadUserLlmSecret(ctx.db, ctx.userId!); const baseUrl = input?.baseUrl?.trim() || saved?.baseUrl; if (!baseUrl) return { ok: false as const, error: 'No base URL configured' }; const apiKey = input?.apiKey !== undefined && input.apiKey !== '' ? input.apiKey : saved?.apiKey ?? null; const model = input?.model?.trim() || saved?.model || 'gpt-4o-mini'; const result = await testEndpoint({ baseUrl, apiKey, model }); if (result.ok) { // touch status read for UI getUserLlmStatus(ctx.db, ctx.userId!); } return result; }), }); const reportsRouter = router({ /** Generate a research note report. */ generate: protectedProcedure .input(z.object({ scope: z.enum(['symbol', 'watchlist', 'portfolio', 'rotation', 'sizing_year', 'risk_posture']), symbol: z.string().optional(), data: z.record(z.string(), z.unknown()).optional().default({}), })) .query(async ({ ctx, input }) => { const { generateReport } = await import('../reports/ReportRunner.ts'); return generateReport({ scope: input.scope, symbol: input.symbol, data: input.data as Record, }); }), }); // ─── Screener Router (Slice 13) ───────────────────────────────────────────── const screenerRouter = router({ /** Filter screener — evaluate a filter expression over a universe. */ filter: protectedProcedure .input(z.object({ expression: z.string().min(1), scope: z.enum(['watchlist', 'sector']).default('watchlist'), sector: z.string().optional(), })) .query(async ({ ctx, input }) => { const { filterUniverse, scopeUniverse } = await import('../screener/UniverseEvaluator.ts'); // Build universe from cache (simplified — uses watchlist symbols) const userId = ctx.userId as string; const wlRow = ctx.db.prepare('SELECT symbols FROM watchlists WHERE owner_id=? ORDER BY sort_order LIMIT 1').get(userId) as { symbols: string } | undefined; const symbols: string[] = wlRow ? JSON.parse(wlRow.symbols) : []; const universe: SymbolUniverseData[] = []; for (const sym of symbols) { const entry = await ctx.cache.get(`yfinance:quote:${sym}`); const quote = entry.value as { regularMarketPrice?: number; regularMarketVolume?: number; averageVolume?: number } | null; universe.push({ symbol: sym, price: quote?.regularMarketPrice, volume: quote?.regularMarketVolume, avgVolume: quote?.averageVolume, }); } return { results: filterUniverse(input.expression, universe), scope: input.scope }; }), /** Strategy screener — screen universe against strategy entry conditions. */ strategy: protectedProcedure .input(z.object({ strategyId: z.string() })) .query(async ({ ctx, input }) => { const { screenByStrategy } = await import('../screener/UniverseEvaluator.ts'); const strat = ctx.db.prepare('SELECT * FROM strategies WHERE id=? AND owner_id=?').get(input.strategyId, ctx.userId) as { components: string } | undefined; if (!strat) throw new TRPCError({ code: 'NOT_FOUND', message: 'Strategy not found.' }); const components = JSON.parse(strat.components) as Array<{ type: string; conditions?: string[] }>; const setup = components.find((c) => c.type === 'setup'); if (!setup?.conditions) return { results: [] }; const userId = ctx.userId as string; const wlRow = ctx.db.prepare('SELECT symbols FROM watchlists WHERE owner_id=? ORDER BY sort_order LIMIT 1').get(userId) as { symbols: string } | undefined; const symbols: string[] = wlRow ? JSON.parse(wlRow.symbols) : []; const universe: SymbolUniverseData[] = []; for (const sym of symbols) { const entry = await ctx.cache.get(`yfinance:quote:${sym}`); const quote = entry.value as { regularMarketPrice?: number; regularMarketVolume?: number; averageVolume?: number } | null; universe.push({ symbol: sym, price: quote?.regularMarketPrice, volume: quote?.regularMarketVolume, avgVolume: quote?.averageVolume }); } return { results: screenByStrategy(setup.conditions, universe) }; }), }); // ─── Strategy + Backtest Router (Slice 12) ────────────────────────────────── const strategyRouter = router({ list: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.userId as string; return ctx.db.prepare('SELECT id, name, components, unlocked, created_at FROM strategies WHERE owner_id=? ORDER BY created_at DESC').all(userId); }), create: protectedProcedure .input(z.object({ name: z.string().min(1), components: z.array(z.unknown()) })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const id = randomUUID(); ctx.db.prepare('INSERT INTO strategies (id, owner_id, name, components, unlocked, created_at) VALUES (?,?,?,?,?,?)').run(id, userId, input.name, JSON.stringify(input.components), 0, new Date().toISOString()); return { id }; }), listPresets: protectedProcedure.query(async ({ ctx }) => { return ctx.db.prepare('SELECT * FROM strategy_presets ORDER BY created_at ASC').all(); }), get: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const userId = ctx.userId as string; const row = ctx.db.prepare('SELECT * FROM strategies WHERE id=? AND owner_id=?').get(input.id, userId); if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Strategy not found.' }); return row; }), getPreset: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const preset = ctx.db.prepare('SELECT * FROM strategy_presets WHERE id=?').get(input.id); if (!preset) throw new TRPCError({ code: 'NOT_FOUND', message: 'Preset not found.' }); return preset; }), forkPreset: protectedProcedure .input(z.object({ presetId: z.string() })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const preset = ctx.db.prepare('SELECT * FROM strategy_presets WHERE id=?').get(input.presetId) as Record | undefined; if (!preset) throw new TRPCError({ code: 'NOT_FOUND', message: 'Preset not found.' }); const id = randomUUID(); const now = new Date().toISOString(); ctx.db.prepare(` INSERT INTO strategies (id, owner_id, name, preset_id, scope, regime_gate, setup, components, exit_profile, risk_policy, is_public, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,0,?) `).run(id, userId, preset.name, preset.id, preset.scope, 'any', preset.entry_rules, preset.entry_rules, preset.exit_profile, preset.risk_policy, now); return { id, name: preset.name }; }), suggestTickers: protectedProcedure .input(z.object({ presetId: z.string() })) .query(async ({ ctx, input }) => { const preset = ctx.db.prepare('SELECT * FROM strategy_presets WHERE id=?').get(input.presetId) as Record | undefined; if (!preset) throw new TRPCError({ code: 'NOT_FOUND', message: 'Preset not found.' }); // Load available data for live suggestions const rotationData = ctx.db.prepare(` SELECT symbol, kind, rs_1m, rs_1w, leadership FROM rotation_rank_snapshots WHERE date = (SELECT MAX(date) FROM rotation_rank_snapshots) ORDER BY rs_1m DESC `).all() as Array<{ symbol: string; kind: string; rs_1m: number | null; rs_1w: number | null; leadership: string }>; const symbolsData = ctx.db.prepare('SELECT symbol, name, sector, ticker_kind FROM symbols').all() as Array<{ symbol: string; name: string | null; sector: string | null; ticker_kind: string }>; const symbolsMap = new Map(symbolsData.map(s => [s.symbol, s])); const dividendData = ctx.db.prepare('SELECT symbol, dividend_yield, payout_ratio, growth_streak_years FROM dividend_fundamentals').all() as Array<{ symbol: string; dividend_yield: number | null; payout_ratio: number | null; growth_streak_years: number | null }>; const dividendMap = new Map(dividendData.map(d => [d.symbol, d])); // Helper: get symbol name from map or return null const nameOf = (s: string): string | null => symbolsMap.get(s)?.name ?? null; // Helper: get tickers in leading rotation sectors const leadingSectors = new Set(rotationData.filter(r => r.leadership === 'leading').map(r => r.symbol)); const rotatedSymbols = rotationData.map(r => r.symbol); // Helper: find equities in a sector from symbols table (limited to the 102 we track) const stocksInSectors = (sectors: string[]): string[] => symbolsData.filter(s => s.ticker_kind === 'equity' && s.sector && sectors.some(sec => s.sector!.includes(sec))).map(s => s.symbol); interface Suggestion { symbol: string; name: string | null; role: string; allocationPct: number; reason: string; source: 'live' | 'default'; } let suggestions: Suggestion[] = []; switch (input.presetId) { case 'dca_accumulation': { // Top ETFs + top large-cap stocks from rotation leaders const etfs = symbolsData.filter(s => s.ticker_kind === 'etf').slice(0, 3); suggestions = etfs.map((e, i) => ({ symbol: e.symbol, name: e.name, role: 'Core holding', allocationPct: [60, 25, 15][i] ?? 10, reason: 'Broad market ETF - low cost diversification', source: 'live' as const, })); if (suggestions.length === 0) { suggestions = [ { symbol: 'VOO', name: 'S&P 500 ETF', role: 'Core holding', allocationPct: 60, reason: 'Broad market low-cost ETF', source: 'default' }, { symbol: 'IVV', name: 'S&P 500 ETF (iShares)', role: 'Core holding', allocationPct: 40, reason: 'Alternative broad market ETF', source: 'default' }, ]; } break; } case 'dividend_growth': { // Live: query dividend_fundamentals for yield >= 2%, payout <= 60% const candidates = dividendData.filter(d => d.dividend_yield != null && d.dividend_yield >= 2 && d.payout_ratio != null && d.payout_ratio <= 60 ).sort((a, b) => (b.growth_streak_years ?? 0) - (a.growth_streak_years ?? 0)).slice(0, 4); if (candidates.length > 0) { const pcts = [40, 25, 20, 15]; suggestions = candidates.map((c, i) => ({ symbol: c.symbol, name: nameOf(c.symbol), role: 'Dividend growth', allocationPct: pcts[i] ?? 10, reason: c.growth_streak_years ? `${c.growth_streak_years}+ year dividend growth streak` : 'Stable dividend payer', source: 'live' as const, })); } else { suggestions = [ { symbol: 'SCHD', name: 'Schwab US Dividend Equity ETF', role: 'Dividend growth', allocationPct: 40, reason: 'Strong dividend growth history', source: 'default' }, { symbol: 'JNJ', name: 'Johnson & Johnson', role: 'Dividend growth', allocationPct: 20, reason: '60+ years of dividend growth', source: 'default' }, { symbol: 'KO', name: 'Coca-Cola', role: 'Dividend growth', allocationPct: 20, reason: '60+ years of dividend growth', source: 'default' }, { symbol: 'PG', name: 'Procter & Gamble', role: 'Dividend growth', allocationPct: 20, reason: '130+ years of dividends', source: 'default' }, ]; } break; } case 'core_satellite': { // Core: fixed ETFs. Satellite: top rotation ETFs + top stock in leading sector. const coreEtfs = symbolsData.filter(s => ['VOO', 'VTI', 'IVV', 'VXUS'].includes(s.symbol)); suggestions = coreEtfs.length > 0 ? coreEtfs.slice(0, 2).map((e, i) => ({ symbol: e.symbol, name: e.name, role: 'Core', allocationPct: [50, 20][i] ?? 15, reason: i === 0 ? 'Broad market core holding' : 'International diversification', source: 'live' as const, })) : [ { symbol: 'VOO', name: 'S&P 500 ETF', role: 'Core', allocationPct: 50, reason: 'Broad market core holding', source: 'default' }, { symbol: 'VXUS', name: 'Total International Stock ETF', role: 'Core', allocationPct: 20, reason: 'International diversification', source: 'default' }, ]; // Satellite: top ETF from rotation leaders const topRotated = rotationData.filter(r => r.leadership === 'leading' || r.rs_1m != null).slice(0, 2); suggestions.push(...topRotated.map((r, i) => ({ symbol: r.symbol, name: nameOf(r.symbol) ?? r.symbol, role: i === 0 ? 'Satellite - Growth' : 'Satellite - Thematic', allocationPct: [15, 15][i] ?? 10, reason: r.rs_1m ? `Sector momentum: 1M RS ${r.rs_1m > 0 ? '+' : ''}${r.rs_1m.toFixed(1)}pp` : 'Rotation leader', source: 'live' as const, }))); if (suggestions.length < 4) { suggestions.push({ symbol: 'QQQ', name: 'Nasdaq 100 ETF', role: 'Satellite - Growth', allocationPct: 15, reason: 'Tech-heavy growth exposure', source: 'default' }); } break; } case 'barbell_strategy': { // Safe core + bond ETF + top growth stocks from rotation suggestions = [ { symbol: 'VOO', name: nameOf('VOO') ?? 'S&P 500 ETF', role: 'Safe core', allocationPct: 50, reason: 'Broad market core', source: 'live' as const }, { symbol: 'BND', name: nameOf('BND') ?? 'Total Bond Market ETF', role: 'Safe bonds', allocationPct: 30, reason: 'Bond diversification', source: 'live' as const }, ]; // Growth picks: top equities from symbols in leading sectors, or fallback const growthPicks = symbolsData.filter(s => s.ticker_kind === 'equity' && s.sector && leadingSectors.size > 0 && Array.from(leadingSectors).some(ls => s.sector!.includes(ls.replace(' ', '/'))) ).slice(0, 2); if (growthPicks.length >= 2) { suggestions.push({ symbol: growthPicks[0].symbol, name: growthPicks[0].name, role: 'Growth bet', allocationPct: 10, reason: 'Leading sector momentum', source: 'live' }); suggestions.push({ symbol: growthPicks[1].symbol, name: growthPicks[1].name, role: 'Growth bet', allocationPct: 10, reason: 'Sector rotation leader', source: 'live' }); } else { suggestions.push({ symbol: 'NVDA', name: 'NVIDIA Corporation', role: 'Growth bet', allocationPct: 10, reason: 'High-growth tech leader', source: 'default' }); suggestions.push({ symbol: 'AMZN', name: 'Amazon.com Inc', role: 'Growth bet', allocationPct: 10, reason: 'High-growth consumer/cloud leader', source: 'default' }); } break; } case 'covered_call_wheel': { // Large-cap liquid stocks with active options const liquidStocks = symbolsData.filter(s => s.ticker_kind === 'equity' && ['AAPL', 'MSFT', 'NVDA', 'AMZN', 'GOOGL', 'META', 'TSLA'].includes(s.symbol)).slice(0, 4); if (liquidStocks.length >= 4) { const pcts = [30, 30, 20, 20]; suggestions = liquidStocks.map((s, i) => ({ symbol: s.symbol, name: s.name, role: 'Covered call', allocationPct: pcts[i] ?? 15, reason: 'High liquidity, strong options market', source: 'live' as const, })); } else { suggestions = [ { symbol: 'AAPL', name: 'Apple Inc.', role: 'Covered call', allocationPct: 30, reason: 'High liquidity, strong options market', source: 'default' }, { symbol: 'MSFT', name: 'Microsoft Corporation', role: 'Covered call', allocationPct: 30, reason: 'High liquidity, stable price', source: 'default' }, { symbol: 'NVDA', name: 'NVIDIA Corporation', role: 'Covered call', allocationPct: 20, reason: 'Active options chain', source: 'default' }, { symbol: 'AMZN', name: 'Amazon.com Inc', role: 'Covered call', allocationPct: 20, reason: 'High liquidity options market', source: 'default' }, ]; } break; } default: { // Fallback: use example_tickers from preset const examples = preset.example_tickers as string | null; if (examples) { try { suggestions = JSON.parse(examples); } catch { suggestions = []; } } } } return suggestions; }), }); const backtestRouter = router({ run: protectedProcedure .input(z.object({ strategyId: z.string(), symbol: z.string(), timeframe: z.enum(['1d', '1wk']).default('1d') })) .query(async ({ ctx, input }) => { const { BacktestEngine } = await import('../strategy/BacktestEngine.ts'); const strat = ctx.db.prepare('SELECT * FROM strategies WHERE id=? AND owner_id=?').get(input.strategyId, ctx.userId) as { id: string; owner_id: string; name: string; components: string; unlocked: number; created_at: string } | undefined; if (!strat) throw new TRPCError({ code: 'NOT_FOUND', message: 'Strategy not found.' }); const strategy = { id: strat.id, ownerId: strat.owner_id, name: strat.name, components: JSON.parse(strat.components), unlocked: !!strat.unlocked, createdAt: strat.created_at, }; const candleEntry = await ctx.cache.get(`yfinance:candles:${input.symbol}:${input.timeframe}`); const candles = candleEntry.value ?? []; const engine = new BacktestEngine(); return engine.run(strategy, input.symbol.toUpperCase(), candles as any[], input.timeframe); }), evaluateLatest: protectedProcedure .input(z.object({ strategyId: z.string(), symbol: z.string() })) .query(async ({ ctx, input }) => { const { BacktestEngine } = await import('../strategy/BacktestEngine.ts'); const strat = ctx.db.prepare('SELECT * FROM strategies WHERE id=? AND owner_id=?').get(input.strategyId, ctx.userId) as { id: string; owner_id: string; name: string; components: string; unlocked: number; created_at: string } | undefined; if (!strat) throw new TRPCError({ code: 'NOT_FOUND', message: 'Strategy not found.' }); const strategy = { id: strat.id, ownerId: strat.owner_id, name: strat.name, components: JSON.parse(strat.components), unlocked: !!strat.unlocked, createdAt: strat.created_at, }; const candleEntry = await ctx.cache.get(`yfinance:candles:${input.symbol}:1d`); const candles = candleEntry.value ?? []; const engine = new BacktestEngine(); return engine.evaluateLatest(strategy, candles as any[]); }), runPortfolio: protectedProcedure .input(z.object({ allocations: z.array(z.object({ symbol: z.string(), targetPct: z.number() })), rebalanceFreq: z.enum(['none', 'quarterly', 'annual']).default('quarterly'), driftThreshold: z.number().default(5), })) .query(async ({ ctx, input }) => { const { PortfolioBacktestEngine } = await import('../strategy/PortfolioBacktestEngine.ts'); const candles: Record = {}; for (const a of input.allocations) { const entry = await ctx.cache.get(`yfinance:candles:${a.symbol}:1d`); candles[a.symbol] = entry.value ?? []; } const engine = new PortfolioBacktestEngine(); return engine.run({ allocations: input.allocations, candles, rebalanceFreq: input.rebalanceFreq, driftThreshold: input.driftThreshold, }); }), }); // ─── Sector Crosslink Router (Slice 14) ───────────────────────────────────── const sectorCrosslinkRouter = router({ confirm: protectedProcedure .input(z.object({ strategyId: z.string(), sector: z.string() })) .query(async ({ ctx, input }) => { const { crossLinkSector } = await import('../screener/SectorCrosslink.ts'); const strat = ctx.db.prepare('SELECT * FROM strategies WHERE id=? AND owner_id=?').get(input.strategyId, ctx.userId) as { components: string } | undefined; if (!strat) throw new TRPCError({ code: 'NOT_FOUND', message: 'Strategy not found.' }); const components = JSON.parse(strat.components) as Array<{ type: string; conditions?: string[] }>; const setup = components.find((c) => c.type === 'setup'); const conditions = setup?.conditions ?? []; // Get rotation signals from DB if available const signals = ctx.db.prepare('SELECT * FROM rotation_signals ORDER BY ts DESC LIMIT 10').all() as Array>; const rotationSignals = signals.map((s) => ({ fromSector: String(s.from_sector ?? ''), toSector: String(s.to_sector ?? s.sector ?? ''), confidence: Number(s.confidence ?? 50), date: String(s.ts ?? ''), })); const universe: any[] = []; return crossLinkSector(input.strategyId, input.sector, conditions, universe, rotationSignals); }), }); // ─── Derisking Router (Slice 23) ──────────────────────────────────────────── const deriskingRouter = router({ suggest: protectedProcedure .input(z.object({ symbol: z.string(), currentPrice: z.number(), avgCost: z.number(), shares: z.number(), ema21: z.number().optional(), ema50: z.number().optional(), thesisStatus: z.enum(['intact', 'weakening', 'broken']).optional(), currentRegime: z.enum(['trending-up', 'trending-down', 'range-bound']).optional(), portfolioCorrelation: z.number().optional(), profitTargets: z.array(z.number()).optional(), dividendYield: z.number().optional(), payoutRatio: z.number().optional(), })) .query(async ({ ctx, input }) => { const { suggestDerisking } = await import('../derisking/DeriskingEngine.ts'); return { suggestions: suggestDerisking(input) }; }), dividendHealth: protectedProcedure .input(z.object({ symbol: z.string() })) .query(async ({ ctx, input }) => { const entry = await ctx.cache.get>(`yfinance:dividendFundamentals:${input.symbol}`); return { data: entry.value }; }), }); // ─── Macro Router (Slice 20) ──────────────────────────────────────────────── const macroRouter = router({ series: protectedProcedure .input(z.object({ seriesId: z.string() })) .query(async ({ ctx, input }) => { const { FredAdapterImpl } = await import('../macro/FredAdapter.ts'); const adapter = new FredAdapterImpl(); return adapter.series(input.seriesId); }), calendar: protectedProcedure.query(async ({ ctx }) => { // Return cached economic calendar events const entry = await ctx.cache.get('macro:calendar'); return { events: entry.value ?? [], isStale: entry.isStale }; }), regimeClassify: protectedProcedure.query(async ({ ctx }) => { const { classifyRegime } = await import('../macro/MacroRegime.ts'); const input: Record = {}; try { const gdpEntry = await ctx.cache.get('fred:series:GDP'); if (gdpEntry?.value) { const obs = (gdpEntry.value as any).observations; if (obs?.length) input.gdpGrowth = obs[obs.length - 1].value; } } catch { /* no cached GDP data */ } try { const cpiEntry = await ctx.cache.get('fred:series:CPIAUCSL'); if (cpiEntry?.value) { const obs = (cpiEntry.value as any).observations; if (obs?.length) input.cpi = obs[obs.length - 1].value; } } catch { /* no cached CPI data */ } return classifyRegime(input); }), commentary: publicProcedure.query(async ({ ctx }) => { const { classifyRegime, generateMacroCommentary } = await import('../macro/MacroRegime.ts'); const input: Record = {}; try { const gdpEntry = await ctx.cache.get('fred:series:GDP'); if (gdpEntry?.value) { const obs = (gdpEntry.value as any).observations; if (obs?.length) input.gdpGrowth = obs[obs.length - 1].value; } } catch { /* no cached GDP data */ } try { const cpiEntry = await ctx.cache.get('fred:series:CPIAUCSL'); if (cpiEntry?.value) { const obs = (cpiEntry.value as any).observations; if (obs?.length) input.cpi = obs[obs.length - 1].value; } } catch { /* no cached CPI data */ } try { const unrateEntry = await ctx.cache.get('fred:series:UNRATE'); if (unrateEntry?.value) { const obs = (unrateEntry.value as any).observations; if (obs?.length) input.unemployment = obs[obs.length - 1].value; } } catch { /* no cached unemployment data */ } try { const gs10Entry = await ctx.cache.get('fred:series:GS10'); if (gs10Entry?.value) { const obs = (gs10Entry.value as any).observations; if (obs?.length) input.treasury10Y = obs[obs.length - 1].value; } } catch { /* no cached 10Y data */ } const classification = classifyRegime(input); return generateMacroCommentary(classification); }), regimeHistory: protectedProcedure.query(async ({ ctx }) => { return ctx.db.prepare('SELECT * FROM regime_history ORDER BY date DESC LIMIT 50').all(); }), }); // ─── Thesis Monitor Router (Slice 21) ─────────────────────────────────────── const thesisMonitorRouter = router({ assess: protectedProcedure .input(z.object({ symbol: z.string() })) .query(async ({ ctx, input }) => { const { assessThesis } = await import('../thesis/ThesisMonitor.ts'); const userId = ctx.userId as string; const thesis = ctx.db.prepare('SELECT * FROM theses WHERE user_id=? AND symbol=? ORDER BY updated_at DESC LIMIT 1').get(userId, input.symbol.toUpperCase()) as { statement: string; invalidation_criteria: string } | undefined; if (!thesis) throw new TRPCError({ code: 'NOT_FOUND', message: 'No thesis found for this symbol.' }); const events: ThesisEvent[] = []; // Gather events from DB (form4, 13f, etc.) return assessThesis({ symbol: input.symbol.toUpperCase(), statement: thesis.statement, invalidationCriteria: JSON.parse(thesis.invalidation_criteria) as string[], createdAt: new Date().toISOString(), }, events); }), timeline: protectedProcedure.query(async ({ ctx }) => { const { buildTimeline } = await import('../thesis/ThesisMonitor.ts'); const userId = ctx.userId as string; const theses = ctx.db.prepare('SELECT * FROM theses WHERE user_id=?').all(userId) as Array>; // For each thesis, get assessment (simplified) return { timeline: [], theses }; }), }); // ─── X/Cookie + Reddit Router (Slice 16) ──────────────────────────────────── const xRouter = router({ // --------------------------------------------------------------------------- // X / BirdClaw — cashtag search, trusted timeline, combined feed. // Credentials are loaded from DB per-request (never hardcoded). When absent the // endpoint returns an empty/`configured:false` response instead of crashing. // --------------------------------------------------------------------------- // Per-symbol feed: tracked poster AND ticker must both match. // (1) author is in x_accounts for this symbol, (2) post mentions $TICKER or bare TICKER. // Global cashtag noise (random accounts) is excluded. feed: protectedProcedure .input(z.object({ symbol: z.string().min(1).max(20), limit: z.number().min(5).max(200).optional().default(50), cursor: z.string().optional() })) .query(async ({ ctx, input }) => { const creds = await loadXCredentials(ctx); if (!creds) return { cashtagPosts: [], accountPosts: [], configured: false as const }; const sym = input.symbol.toUpperCase(); const sinceMs = Date.now() - 30 * 24 * 60 * 60 * 1000; const handles = (ctx.db.prepare( 'SELECT handle FROM x_accounts WHERE symbol=?', ).all(sym) as Array<{ handle: string }>).map((h) => h.handle.toLowerCase()); type PostRow = { post_id: string; author_handle: string; cashtag: string | null; body_text: string | null; posted_at: string; engagement: number; sentiment_score?: number | null; attribution?: string | null; }; if (handles.length === 0) { return { cashtagPosts: [], accountPosts: [], nextCursor: null, configured: true as const, health: null, }; } // Escape for RegExp (tickers are usually alnum but keep safe). const esc = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $TICKER or bare TICKER as a token (not a substring of another word). const tickerRe = new RegExp(`(?:^|[^A-Za-z0-9])\\$?${esc}(?=[^A-Za-z0-9]|$)`, 'i'); const mentionsTicker = (p: PostRow): boolean => { const tag = (p.cashtag ?? '').replace(/^\$/, '').toUpperCase(); if (tag === sym) return true; return tickerRe.test(p.body_text ?? ''); }; const inWindow = (p: PostRow) => { const t = Date.parse(p.posted_at); return !Number.isFinite(t) || t >= sinceMs; }; // Only posts from accounts explicitly paired with this symbol. let filtered = (ctx.db.prepare( `SELECT post_id, author_handle, cashtag, body_text, posted_at, engagement, sentiment_score, attribution FROM x_cookie_posts WHERE lower(author_handle) IN (${handles.map(() => '?').join(',')}) ORDER BY posted_at DESC LIMIT 800`, ).all(...handles) as PostRow[]).filter((p) => inWindow(p) && mentionsTicker(p)); filtered.sort((a, b) => Date.parse(b.posted_at) - Date.parse(a.posted_at)); if (input.cursor) { const cMs = Date.parse(input.cursor); filtered = filtered.filter((p) => { const t = Date.parse(p.posted_at); if (Number.isFinite(cMs) && Number.isFinite(t)) return t < cMs; return p.posted_at < input.cursor!; }); } const page = filtered.slice(0, input.limit); const nextCursor = page.length === input.limit ? page[page.length - 1].posted_at : null; // Background refresh: timelines for tracked handles only (cashtag search is optional ingest, not feed source). try { for (const h of handles) { try { ctx.queue.queue(`x:timeline:${h}`); } catch { /* ignore */ } } } catch { /* ignore */ } const healthRow = ctx.db.prepare('SELECT healthy, last_error, updated_at FROM x_credentials WHERE id=?').get('singleton') as { healthy?: number; last_error?: string | null } | undefined; return { cashtagPosts: page, accountPosts: [], nextCursor, configured: true as const, health: healthRow ? { healthy: healthRow.healthy === 1 ? ('healthy' as const) : ('degraded' as const), lastError: healthRow.last_error ?? null, } : null, }; }), // Raw cashtag search (no per-account merge). cashtag_search: protectedProcedure .input(z.object({ symbol: z.string().min(1).max(20), limit: z.number().min(5).max(200).optional().default(20) })) .query(async ({ ctx, input }) => { const creds = await loadXCredentials(ctx); if (!creds) return { posts: [], configured: false as const }; const { XCookieAdapter } = await import('../adapters/XCookieAdapter.ts'); const adapter = new XCookieAdapter(creds, (health: XCookieHealth) => updateXHealth(ctx.db, health.sourceStatus, health.lastError ?? undefined)); try { const posts = (await adapter.cashtagSearch(input.symbol, {})).value; return { posts, configured: true as const }; } catch (e) { throw new TRPCError({ code: 'BAD_GATEWAY', message: e instanceof Error ? e.message : 'X request failed.' }); } }), // Single trusted account timeline. timeline: protectedProcedure .input(z.object({ handle: z.string().min(1).regex(/^[a-zA-Z0-9_]+$/), limit: z.number().min(5).max(200).optional().default(20) })) .query(async ({ ctx, input }) => { const creds = await loadXCredentials(ctx); if (!creds) return { posts: [], configured: false as const }; const { XCookieAdapter } = await import('../adapters/XCookieAdapter.ts'); const adapter = new XCookieAdapter(creds, (health: XCookieHealth) => updateXHealth(ctx.db, health.sourceStatus, health.lastError ?? undefined)); try { const posts = (await adapter.trustedTimeline(input.handle, {})).value; return { posts, configured: true as const }; } catch (e) { throw new TRPCError({ code: 'BAD_GATEWAY', message: e instanceof Error ? e.message : 'X request failed.' }); } }), // Per-symbol tracked account list. accountsForSymbol: protectedProcedure .input(z.object({ symbol: z.string().min(1).max(20) })) .query(({ ctx, input }) => { const rows = ctx.db.prepare('SELECT id, handle, COALESCE(label, \'\' ) AS label FROM x_accounts WHERE symbol=? ORDER BY handle').all(input.symbol.toUpperCase()) as Array<{id: string; handle: string; label: string}>; return (rows ?? []); }), }); const redditRouter = router({ subreddit: protectedProcedure .input(z.object({ subreddit: z.string(), limit: z.number().min(1).max(100).optional().default(25) })) .query(async ({ ctx, input }) => { const { RedditAdapter } = await import('../adapters/RedditAdapter.ts'); const adapter = new RedditAdapter(); try { const result = await adapter.subredditPosts(input.subreddit, undefined, {}); return { posts: result.value, isStale: false }; } catch (e) { throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message }); } }), search: protectedProcedure .input(z.object({ q: z.string(), limit: z.number().min(1).max(100).optional().default(25) })) .query(async ({ ctx, input }) => { const { RedditAdapter } = await import('../adapters/RedditAdapter.ts'); const adapter = new RedditAdapter(); try { const result = await adapter.searchPosts(input.q, {}); return { posts: result.value, isStale: false }; } catch (e) { throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message }); } }), }); // Slice 8 — Institutional Dashboard Rollup (read-only, no trade actions). // ─── Sizing Router (P0: wire-sizing-risk-trpc-m20) ──────────────────────────── // Pure SizingEngine behind tRPC. Outputs math + plain-English layers only (ADR-0007). const sizingRouter = router({ compute: protectedProcedure .input(z.object({ symbol: z.string().min(1).max(20), tier: z.enum(['A_STAR', 'A', 'B', 'C']), riskFraction: z.number().positive().max(0.25), stopPerShare: z.number().positive(), equity: z.number().positive(), complexity: z.enum(['beginner', 'intermediate', 'advanced']).optional(), regime: z.enum(['trending-up', 'trending-down', 'range-bound']).default('range-bound'), aStarUnlocked: z.boolean().optional(), macroOverrideReason: z.string().min(3).max(500).optional(), holdings: z.array(z.object({ symbol: z.string(), shares: z.number(), avgCost: z.number(), cluster: z.string().default('uncategorized'), })).optional(), clusterCaps: z.record(z.string(), z.number()).nullable().optional(), })) .query(async ({ ctx, input }) => { const { sizePosition } = await import('../sizing/SizingEngine.ts'); const userId = ctx.userId as string; const user = ctx.db.prepare( 'SELECT complexity FROM users WHERE id=?', ).get(userId) as { complexity: string } | undefined; const complexity = (input.complexity ?? user?.complexity ?? 'beginner') as 'beginner' | 'intermediate' | 'advanced'; // Prefer live portfolio when client did not pass holdings. let holdings = input.holdings ?? []; if (!input.holdings) { const { listHoldings } = await import('../db/portfolioRepository.ts'); holdings = listHoldings(ctx.db, userId).map((h) => ({ symbol: h.symbol, shares: h.shares, avgCost: h.avg_cost, cluster: 'uncategorized', })); } const equity = input.equity; const defaultCap = complexity === 'beginner' ? equity * 0.25 : complexity === 'intermediate' ? equity * 0.40 : null; const clusterCaps = input.clusterCaps !== undefined ? input.clusterCaps : (defaultCap !== null ? { uncategorized: defaultCap } : null); const unlockRow = ctx.db.prepare( "SELECT 1 AS ok FROM sizing_unlocks WHERE user_id=? AND unlock='tier_a_star' LIMIT 1", ).get(userId) as { ok: number } | undefined; const aStarUnlocked = input.aStarUnlocked ?? !!unlockRow; // Gentle-halt: when active, risk fraction is educational-only but we still // surface the halt flag so the UI can teach the circuit breaker. const { isHalted } = await import('../risk/haltCircuitBreaker.ts'); const halted = isHalted(ctx.db, userId); const result = sizePosition( { symbol: input.symbol.toUpperCase(), tier: input.tier, riskFraction: halted ? Math.min(input.riskFraction, 0.005) : input.riskFraction, stopPerShare: input.stopPerShare, }, { equity, complexity }, { holdings, regime: input.regime, clusterCaps, aStarUnlocked, }, { aStarUnlocked, macroOverride: input.macroOverrideReason ? { reason: input.macroOverrideReason } : null, }, ); return { ...result, halted, footer: 'Educational observation only.', }; }), }); // ─── Risk Posture Router ──────────────────────────────────────────────────── const riskRouter = router({ /** Simplified risk posture: holdings, drawdown vs tolerance, basic metrics. */ posture: protectedProcedure .input(z.object({ equity: z.number().positive(), peakEquity: z.number().positive().optional(), drawdownTolerancePct: z.number().positive().max(100).optional(), regime: z.enum(['trending-up', 'trending-down', 'range-bound']).optional(), plans: z.array(z.object({ symbol: z.string(), stopPrice: z.number().optional(), rewardTarget: z.number().optional(), cluster: z.string().optional(), })).optional(), })) .query(async ({ ctx, input }) => { const { assessRisk, ADR_0007_FOOTER } = await import('../risk/RiskEngine.ts'); const { listHoldings } = await import('../db/portfolioRepository.ts'); const { listOptionLegs } = await import('../db/portfolioOptionRepository.ts'); const { assessOptionRiskContribution, mergeOptionCapitalIntoClusters, } = await import('../risk/optionRiskContribution.ts'); const userId = ctx.userId as string; const user = ctx.db.prepare( 'SELECT complexity, drawdown_tolerance FROM users WHERE id=?', ).get(userId) as { complexity: string; drawdown_tolerance: number | null } | undefined; const complexity = (user?.complexity ?? 'beginner') as 'beginner' | 'intermediate' | 'advanced'; const drawdownTolerancePct = input.drawdownTolerancePct ?? (typeof user?.drawdown_tolerance === 'number' ? Math.abs(user.drawdown_tolerance) : 20); const planBySymbol = new Map( (input.plans ?? []).map((p) => [p.symbol.toUpperCase(), p]), ); const holdings = listHoldings(ctx.db, userId); const optionLegs = listOptionLegs(ctx.db, userId); const portfolio = holdings.map((h) => { const plan = planBySymbol.get(h.symbol.toUpperCase()); return { symbol: h.symbol, shares: h.shares, avgCost: h.avg_cost, cluster: plan?.cluster ?? 'uncategorized', stopPrice: plan?.stopPrice, rewardTarget: plan?.rewardTarget, }; }); const equity = input.equity; const peakEquity = input.peakEquity ?? equity; const defaultCap = complexity === 'beginner' ? equity * 0.25 : complexity === 'intermediate' ? equity * 0.40 : null; const clusterCaps = defaultCap !== null ? { uncategorized: defaultCap } as Record : null; const optionRisk = assessOptionRiskContribution( optionLegs, holdings.map((h) => ({ symbol: h.symbol, shares: h.shares })), equity, ); const posture = assessRisk({ portfolio, account: { equity, drawdownTolerancePct, complexity }, peakEquity, regime: input.regime, sizingContext: { clusterCaps }, }); const foldUncategorized = complexity === 'beginner'; const clusterExposure = mergeOptionCapitalIntoClusters( posture.clusterExposure, optionRisk.capitalByUnderlying, foldUncategorized, ); const equityExposureUsd = portfolio.reduce((s, p) => s + p.shares * p.avgCost, 0); const totalExposureUsd = equityExposureUsd + optionRisk.capitalCommittedUsd; const recommendedActions = [ ...posture.recommendedActions, ...optionRisk.recommendations, ]; return { ...posture, clusterExposure, recommendedActions, halted: false, halt: null, holdingsCount: holdings.length, optionLegsCount: optionRisk.legsCount, totalExposureUsd, optionRisk: { premiumAtRiskUsd: optionRisk.premiumAtRiskUsd, cashReservedUsd: optionRisk.cashReservedUsd, creditReceivedUsd: optionRisk.creditReceivedUsd, capitalCommittedUsd: optionRisk.capitalCommittedUsd, uncoveredShortCallCount: optionRisk.uncoveredShortCallCount, }, footer: ADR_0007_FOOTER, }; }), }); // ─── Emotion Logger Router (Slice: emotion-logger-storage) ────────────────────── // Emotion Logger Router (Slice: emotion-logger-storage) // --------------------------------------------------------------------------- const emotionLoggerRouter = router({ /** Add an emotion log entry for a trade execution. */ add: protectedProcedure .input(z.object({ tradeExecutionId: z.string(), priceAtEvent: z.number().optional(), emotion: z.string(), note: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const { addEmotionLog } = await import('../db/emotionLogRepository.ts'); try { const log = addEmotionLog(ctx.db, { tradeExecutionId: input.tradeExecutionId, priceAtEvent: input.priceAtEvent, emotion: input.emotion, note: input.note, }); return { success: true, log }; } catch (e) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: (e as Error).message }); } }), /** Get all emotion logs for a trade execution. */ getByTrade: protectedProcedure .input(z.object({ tradeExecutionId: z.string() })) .query(async ({ ctx, input }) => { const { getEmotionLogsByTrade } = await import('../db/emotionLogRepository.ts'); try { const logs = getEmotionLogsByTrade(ctx.db, input.tradeExecutionId); return { logs }; } catch (e) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: (e as Error).message }); } }), /** Delete an emotion log by id. */ delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const { deleteEmotionLog } = await import('../db/emotionLogRepository.ts'); try { deleteEmotionLog(ctx.db, input.id); return { success: true }; } catch (e) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: (e as Error).message }); } }), }); // ─── Trades Router (Phase 5) ───────────────────────────────────────────── const tradesRouter = router({ list: protectedProcedure.query(async ({ ctx }) => { return ctx.db.prepare('SELECT * FROM trades WHERE owner_id=? ORDER BY date DESC').all(ctx.userId as string); }), get: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const row = ctx.db.prepare('SELECT * FROM trades WHERE id=? AND owner_id=?').get(input.id, ctx.userId as string); if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Trade not found.' }); return row; }), save: protectedProcedure .input(z.object({ id: z.string(), symbol: z.string(), date: z.string(), status: z.string(), environment: z.string().optional(), tier: z.string(), strategyId: z.string().optional(), confluenceScore: z.number().optional(), riskPct: z.number().optional(), positionSize: z.number().optional(), entryPrice: z.number().optional(), stopLoss: z.number().optional(), targets: z.string().optional(), justifications: z.string().optional(), checklistCompleted: z.number().optional(), allocationPlanId: z.string().optional(), allocationRole: z.string().optional(), tradeId: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const tradeKey = input.tradeId ?? input.id; const existing = ctx.db.prepare('SELECT id FROM trades WHERE id=? AND owner_id=?').get(input.id, userId) as Record | undefined; if (existing) { ctx.db.prepare(` UPDATE trades SET symbol=?,date=?,status=?,environment=?,tier=?,strategy_id=?, confluence_score=?,risk_pct=?,position_size=?,entry_price=?,stop_loss=?, targets=?,justifications=?,checklist_completed=?,allocation_plan_id=?,allocation_role=?,trade_id=? WHERE id=? AND owner_id=? `).run( input.symbol.toUpperCase(), input.date, input.status, input.environment ?? null, input.tier, input.strategyId ?? null, input.confluenceScore ?? null, input.riskPct ?? null, input.positionSize ?? null, input.entryPrice ?? null, input.stopLoss ?? null, input.targets ?? '[]', input.justifications ?? '{}', input.checklistCompleted ?? 0, input.allocationPlanId ?? null, input.allocationRole ?? null, tradeKey, input.id, userId ); } else { ctx.db.prepare(` INSERT INTO trades (id,owner_id,trade_id,symbol,date,status,environment,tier,strategy_id, confluence_score,risk_pct,position_size,entry_price,stop_loss,targets,justifications,checklist_completed,allocation_plan_id,allocation_role) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) `).run( input.id, userId, tradeKey, input.symbol.toUpperCase(), input.date, input.status, input.environment ?? null, input.tier, input.strategyId ?? null, input.confluenceScore ?? null, input.riskPct ?? null, input.positionSize ?? null, input.entryPrice ?? null, input.stopLoss ?? null, input.targets ?? '[]', input.justifications ?? '{}', input.checklistCompleted ?? 0, input.allocationPlanId ?? null, input.allocationRole ?? null ); } return { ok: true, id: input.id }; }), close: protectedProcedure .input(z.object({ id: z.string(), realizedPnl: z.number(), reflection: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const row = ctx.db.prepare('SELECT justifications FROM trades WHERE id=? AND owner_id=?').get(input.id, userId) as { justifications: string | null } | undefined; if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Trade not found.' }); let just: Record = {}; try { just = JSON.parse(row.justifications || '{}'); } catch { /* ignore */ } if (input.reflection?.trim()) just.closeReflection = input.reflection.trim(); ctx.db.prepare('UPDATE trades SET status=?, realized_pnl=?, justifications=? WHERE id=? AND owner_id=?') .run('closed', input.realizedPnl, JSON.stringify(just), input.id, userId); return { ok: true }; }), stats: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.userId as string; const closed = ctx.db.prepare('SELECT COUNT(*) c FROM trades WHERE owner_id=? AND status=?').get(userId, 'closed') as { c: number } | undefined; const profitable = ctx.db.prepare('SELECT COUNT(*) c FROM trades WHERE owner_id=? AND status=? AND realized_pnl>0').get(userId, 'closed') as { c: number } | undefined; return { totalClosed: closed?.c ?? 0, profitable: profitable?.c ?? 0 }; }), }); const thesesRouter = router({ list: protectedProcedure.query(({ ctx }) => { return ctx.db.prepare( 'SELECT * FROM theses WHERE user_id=? ORDER BY updated_at DESC', ).all(ctx.userId as string); }), get: protectedProcedure .input(z.object({ id: z.string() })) .query(({ ctx, input }) => { const row = ctx.db.prepare('SELECT * FROM theses WHERE id=? AND user_id=?').get(input.id, ctx.userId as string); if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Thesis not found.' }); return row; }), create: protectedProcedure .input(z.object({ symbol: z.string().min(1), statement: z.string().min(1), invalidationCriteria: z.array(z.string()).default([]), status: z.enum(['intact', 'weakening', 'broken']).default('intact'), })) .mutation(({ ctx, input }) => { const id = randomUUID(); const now = new Date().toISOString(); ctx.db.prepare( `INSERT INTO theses (id, user_id, symbol, statement, invalidation_criteria, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`, ).run( id, ctx.userId as string, input.symbol.toUpperCase(), input.statement, JSON.stringify(input.invalidationCriteria), input.status, now, now, ); return { id, symbol: input.symbol.toUpperCase() }; }), update: protectedProcedure .input(z.object({ id: z.string(), statement: z.string().min(1).optional(), invalidationCriteria: z.array(z.string()).optional(), status: z.enum(['intact', 'weakening', 'broken']).optional(), })) .mutation(({ ctx, input }) => { const userId = ctx.userId as string; const row = ctx.db.prepare('SELECT * FROM theses WHERE id=? AND user_id=?').get(input.id, userId) as { statement: string; invalidation_criteria: string; status: string; } | undefined; if (!row) throw new TRPCError({ code: 'NOT_FOUND', message: 'Thesis not found.' }); const statement = input.statement ?? row.statement; const criteria = input.invalidationCriteria ? JSON.stringify(input.invalidationCriteria) : row.invalidation_criteria; const status = input.status ?? row.status; ctx.db.prepare( 'UPDATE theses SET statement=?, invalidation_criteria=?, status=?, updated_at=? WHERE id=? AND user_id=?', ).run(statement, criteria, status, new Date().toISOString(), input.id, userId); return { ok: true }; }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(({ ctx, input }) => { const info = ctx.db.prepare('DELETE FROM theses WHERE id=? AND user_id=?').run(input.id, ctx.userId as string); if (info.changes === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Thesis not found.' }); return { ok: true }; }), }); // ─── Fund Router (M21 Mirror Portfolio) ───────────────────────────────────── const symbolsRouter = router({ /** Autocomplete search over the symbol index (M22). Local-first (ADR-0009). */ search: publicProcedure .input(z.object({ q: z.string().trim().min(1).max(60), limit: z.number().int().min(1).max(20).optional(), })) .query(async ({ ctx, input }) => { const limit = input.limit ?? 10; const q = input.q.toUpperCase(); const db = ctx.db; // Prefix match on symbol first (exact > prefix, shortest first), then name contains. const rows = db.prepare( `SELECT symbol, name, sector, industry, exchange, ticker_kind, cik, CASE WHEN symbol = ? THEN 0 ELSE 1 END AS rank FROM symbols WHERE symbol = ? OR symbol LIKE ? ORDER BY rank ASC, LENGTH(symbol) ASC, symbol ASC LIMIT ?`, ).all(q, q, `${q}%`, limit) as Array>; let results = rows.map((r) => ({ symbol: r.symbol, name: r.name ?? null, sector: r.sector ?? null, industry: r.industry ?? null, exchange: r.exchange ?? null, ticker_kind: r.ticker_kind, cik: r.cik ?? null, })); if (results.length < limit) { const seen = new Set(results.map((r) => r.symbol)); const nameRows = db.prepare( `SELECT symbol, name, sector, industry, exchange, ticker_kind, cik FROM symbols WHERE name IS NOT NULL AND name != '' AND name LIKE ? ORDER BY LENGTH(name) ASC LIMIT ?`, ).all(`%${input.q}%`, limit) as Array>; const fresh = nameRows .filter((r) => !seen.has(r.symbol as string)) .map((r) => ({ symbol: r.symbol, name: r.name ?? null, sector: r.sector ?? null, industry: r.industry ?? null, exchange: r.exchange ?? null, ticker_kind: r.ticker_kind, cik: r.cik ?? null, })); results = [...results, ...fresh.slice(0, limit - results.length)]; } return { q: input.q, results }; }), /** Two-tier fund-holdings strip for a symbol (M22): tracked funds w/ weight, then institution count. */ holders: publicProcedure .input(z.object({ symbol: z.string().trim().toUpperCase().min(1).max(20) })) .query(async ({ ctx, input }) => { const db = ctx.db; const symbol = input.symbol; // Tier 1 — tracked funds holding the symbol (most recent record per fund), with weight in their disclosed book. const trackedRows = db.prepare( `SELECT tf.id AS fund_id, tf.fund_name, fpr.symbol, fpr.shares, fpr.value_usd, fpr.as_of, fpr.source FROM fund_position_records fpr JOIN tracked_funds tf ON tf.id = fpr.fund_id AND tf.enabled = 1 JOIN ( SELECT fund_id, MAX(as_of) AS max_as_of FROM fund_position_records WHERE symbol = ? GROUP BY fund_id ) latest ON latest.fund_id = fpr.fund_id AND latest.max_as_of = fpr.as_of WHERE fpr.symbol = ? ORDER BY tf.fund_name ASC`, ).all(symbol, symbol) as Array>; // Weight = position value / total disclosed book value at the fund's live book (per fund). const fundIds = [...new Set(trackedRows.map((r) => r.fund_id as string))]; const bookValueByFund = new Map(); for (const fid of fundIds) { const { liveBook } = await import('../db/fundRepository.ts'); const book = liveBook(db, fid); const total = book.reduce((s, p) => s + (p.value_usd ?? 0), 0); bookValueByFund.set(fid, total > 0 ? total : 0); } const tracked = trackedRows.map((r) => ({ fundId: r.fund_id, fundName: r.fund_name, shares: r.shares ?? null, valueUsd: r.value_usd ?? null, asOf: r.as_of, source: r.source, weightPct: bookValueByFund.get(r.fund_id) ? ((r.value_usd ?? 0) / bookValueByFund.get(r.fund_id)!) * 100 : null, })); // Tier 2 — how many distinct 13F filers reported holding the symbol. const instRow = db.prepare( `SELECT COUNT(DISTINCT filer_cik) AS n FROM institution_filings WHERE symbol = ?`, ).get(symbol) as { n: number } | undefined; return { symbol, tracked, institutionCount: instRow?.n ?? 0, }; }), }); const fundsRouter = router({ /** List operator-curated tracked funds (v1: Alpine Fox). */ list: publicProcedure .query(async ({ ctx }) => { const { listTrackedFunds } = await import('../db/fundRepository.ts'); return listTrackedFunds(ctx.db, { includeDisabled: true }); }), /** Get a single tracked fund by id. */ get: publicProcedure .input(z.object({ id: z.string().min(1) })) .query(async ({ ctx, input }) => { const { getTrackedFund } = await import('../db/fundRepository.ts'); return getTrackedFund(ctx.db, input.id); }), /** Live Book for a fund — most recent record per symbol, source-labeled. */ liveBook: publicProcedure .input(z.object({ fundId: z.string().min(1) })) .query(async ({ ctx, input }) => { const { liveBook } = await import('../db/fundRepository.ts'); return liveBook(ctx.db, input.fundId); }), /** Full append-only position timeline for a fund. */ records: publicProcedure .input(z.object({ fundId: z.string().min(1) })) .query(async ({ ctx, input }) => { const { listRecords } = await import('../db/fundRepository.ts'); return listRecords(ctx.db, input.fundId); }), /** Admin CRUD — operator-curated registry. */ adminCreate: adminProcedure .input(z.object({ ci_key: z.string().min(1), fund_name: z.string().min(1), manager_name: z.string().min(1), x_handle: z.string().optional(), paywall_status: z.enum(['unknown', 'open', 'paywalled']).optional(), enabled: z.boolean().optional(), })) .mutation(async ({ ctx, input }) => { const { upsertTrackedFund } = await import('../db/fundRepository.ts'); return upsertTrackedFund(ctx.db, input); }), adminSetEnabled: adminProcedure .input(z.object({ id: z.string().min(1), enabled: z.boolean() })) .mutation(async ({ ctx, input }) => { const { setFundEnabled } = await import('../db/fundRepository.ts'); return { updated: setFundEnabled(ctx.db, input.id, input.enabled) }; }), adminDelete: adminProcedure .input(z.object({ id: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const { deleteTrackedFund } = await import('../db/fundRepository.ts'); return { deleted: deleteTrackedFund(ctx.db, input.id) }; }), /** Fetch the fund's full 13F history + refresh Live-Book records. */ adminSync13f: protectedProcedure .input(z.object({ fundId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const { fetchFund13F } = await import('../mirror/fund13fFetcher.ts'); return fetchFund13F(ctx.db, input.fundId); }), /** Materialize position captures from the fund manager's X posts (M21). */ adminIngestCaptures: protectedProcedure .input(z.object({ fundId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const { ingestFundCaptures } = await import('../services/captureIngest.ts'); return ingestFundCaptures(ctx.db, input.fundId); }), }); // ─── Mirror Router (M21) — math, not advice ──────────────────────────────── const mirrorRouter = router({ /** * Mirror diff: target vs the user's actual book. Pure engine output; all * strings are mechanical (ADR-0010). Base defaults to live portfolio equity. */ diff: protectedProcedure .input(z.object({ fundId: z.string().min(1), base: z.number().positive().optional(), // if omitted, use live equity locked: z.boolean().optional(), minPositionUsd: z.number().optional(), minWeightPct: z.number().optional(), tickSize: z.number().optional(), })) .query(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { liveBook } = await import('../db/fundRepository.ts'); const { listHoldings } = await import('../db/portfolioRepository.ts'); const { mirrorBook } = await import('../mirror/mirrorEngine.ts'); const book = liveBook(ctx.db, input.fundId); const holdings = listHoldings(ctx.db, userId); const symbols = Array.from(new Set([...book.map((p) => p.symbol), ...holdings.map((h) => h.symbol)])); const entries = await ctx.cache.getMany(symbols.map((s) => `yfinance:quote:${s}`)); const prices: Record = {}; symbols.forEach((s, i) => { const q = (entries[i]?.value ?? null) as { regularMarketPrice?: number; price?: number } | null; prices[s] = q?.regularMarketPrice ?? q?.price; }); // Actual portfolio equity (sum of market value) for re-baseline default. let equity = 0; for (const h of holdings) { const px = prices[h.symbol]; if (typeof px === 'number') equity += px * h.qty; } const base = input.base ?? (equity > 0 ? equity : 200_000); return mirrorBook( book, holdings.map((h) => ({ symbol: h.symbol, qty: h.shares, avg_cost: h.avg_cost })), prices, { base, locked: input.locked ?? false, minPositionUsd: input.minPositionUsd, minWeightPct: input.minWeightPct, tickSize: input.tickSize, }, ); }), }); // ─── Confluence Signal Engine (M22) ───────────────────────────────────────── const confluenceRouter = router({ /** The read-only 34-slot catalog, grouped by family, with slot metadata. */ slots: publicProcedure.query(async () => { return { slots: CONFLUENCE_SLOTS.map((s) => ({ id: s.id, name: s.name, family: s.family, body: s.body, granularity: s.granularity, explain: s.explain, })), slotIds: CONFLUENCE_SLOT_IDS, families: ['technical', 'institutional', 'macro', 'seasonal', 'flows', 'sentiment'] as SlotFamily[], }; }), /** List rack definitions (system + the calling user's own). */ racks: publicProcedure.query(({ ctx }) => { const repo = new ConfluenceRepository(ctx.db); const system = repo.listSystemRacks(); const user = ctx.userId ? repo.listUserRacks(ctx.userId) : []; return { system, user }; }), /** Latest evaluation for a symbol+rack, with the picture + change hint. */ evaluation: publicProcedure .input(z.object({ symbol: z.string().min(1).max(12), rackId: z.string().min(1).optional(), limit: z.number().int().min(1).max(60).optional(), })) .query(({ ctx, input }) => { const repo = new ConfluenceRepository(ctx.db); const symbol = input.symbol.toUpperCase(); const evals = repo.listEvaluationsForSymbol(symbol); const rackId = input.rackId ?? (repo.listSystemRacks()[0]?.id ?? null); const forRack = rackId ? evals.filter((e) => e.rackId === rackId) : evals; const latest = forRack.length > 0 ? forRack[0] : null; let change = null; if (forRack.length >= 2) { const prev = forRack[1]; change = detectPictureChange( { symbol, asOf: prev.asOf, assessments: prev.assessments, bullEvidence: prev.bullEvidence, bearEvidence: prev.bearEvidence, bullCount: prev.bullCount, bearCount: prev.bearCount, assessedCount: prev.assessedCount, netEvidence: prev.netEvidence, totalEvidence: prev.totalEvidence, quality: prev.quality }, { symbol, asOf: latest!.asOf, assessments: latest!.assessments, bullEvidence: latest!.bullEvidence, bearEvidence: latest!.bearEvidence, bullCount: latest!.bullCount, bearCount: latest!.bearCount, assessedCount: latest!.assessedCount, netEvidence: latest!.netEvidence, totalEvidence: latest!.totalEvidence, quality: latest!.quality }, ); } return { rackId, symbol, latest, history: forRack.slice(0, input.limit ?? 10), change, }; }), /** Run the slot backtest for a symbol from cached daily candles. */ backtest: publicProcedure .input(z.object({ symbol: z.string().min(1).max(12), weeks: z.number().int().min(2).max(12).optional(), })) .query(async ({ ctx, input }) => { const repo = new ConfluenceRepository(ctx.db); const symbol = input.symbol.toUpperCase(); const entry = await ctx.cache.get(`yfinance:candles:${symbol}:1d`); const candles = (entry?.value ?? []) as PriceCandle[]; // Historic fire events come from signal history rows (as-of was the fire date). const signals = repo.listSignalsForSymbol(symbol); const fires: ConfluenceFireEvent[] = signals.map((s) => ({ symbol, asOf: s.firedAt, slotId: s.slotId })); return runSlotBacktest(symbol, candles, fires, input.weeks ?? 4); }), /** Per-slot reliability scorecard from resolved signal history. */ scorecard: publicProcedure .input(z.object({ symbol: z.string().min(1).max(12).optional() })) .query(({ ctx, input }) => { const symbol = input?.symbol?.toUpperCase(); const stats = signalHistoryToStats(ctx.db, symbol); return { slots: stats, // Row semantics: clear, evidence-based. note: 'Follow-through is measured over the standard 4-week horizon. Reliability is context, not a promise.', }; }), /** Create or update a user-owned rack. */ saveRack: protectedProcedure .input(z.object({ id: z.string().min(1).max(80).optional(), name: z.string().min(1).max(80), description: z.string().max(300).optional(), slotIds: z.array(z.string()).min(1).max(34), })) .mutation(({ ctx, input }) => { const repo = new ConfluenceRepository(ctx.db); const userId = ctx.userId!; // Validate slot ids against the catalog. for (const id of input.slotIds) { if (!CONFLUENCE_SLOT_IDS.includes(id)) throw new TRPCError({ code: 'BAD_REQUEST', message: `Unknown slot id: ${id}` }); } let rack = repo.getRack(input.id ?? ''); if (rack && rack.ownerId !== userId) throw new TRPCError({ code: 'FORBIDDEN', message: 'Not your rack.' }); if (rack) { repo.updateRackSlots(rack.id, input.slotIds, input.name, input.description ?? null); rack = repo.getRack(rack.id)!; } else { rack = rackFromSlots(input.id ?? `${userId}-${Date.now()}`, input.name, input.slotIds, { ownerId: userId, description: input.description ?? null, }); repo.saveRack(rack); } return rack; }), }); export const appRouter = router({ auth: authRouter, onboarding: onboardingRouter, market: marketRouter, dashboard: dashboardRouter, admin: adminRouter, alerts: alertsRouter, edgar: edgarRouter, institutional: institutionalRouter, watchlists: watchlistRouter, portfolio: portfolioRouter, options: optionsRouter, dealerMap: dealerMapRouter, dealerStudy: dealerStudyRouter, mentorLedger: mentorLedgerRouter, userLlm: userLlmRouter, reports: reportsRouter, screener: screenerRouter, strategies: strategyRouter, backtest: backtestRouter, sectorCrosslink: sectorCrosslinkRouter, derisking: deriskingRouter, macro: macroRouter, thesisMonitor: thesisMonitorRouter, x: xRouter, reddit: redditRouter, emotionLogger: emotionLoggerRouter, sizing: sizingRouter, risk: riskRouter, trades: tradesRouter, theses: thesesRouter, funds: fundsRouter, mirror: mirrorRouter, symbols: symbolsRouter, confluence: confluenceRouter, }); export type AppRouter = typeof appRouter;