import { initTRPC, TRPCError } from '@trpc/server'; import { z } from 'zod'; 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, defaultDrawdownTolerancePct, defaultRiskTolerance, ONBOARDING_DISCLAIMER, type Complexity } from '../onboarding/starter.ts'; import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts'; import { emaFromCandles, rsi as rsiFn, relativeVolume } from '../analysis/indicators.ts'; import { listUsers, resetPassword, gdprExport, queueHealth, resetQueueBackoff, NotOwnerError, listUserSessions, listAuditLog, queueSecFetch } from '../admin/admin.ts'; import { EdgarAdapter } from '../adapters/EdgarAdapter.ts'; import { OptionsAdapter, parseOptionChainRows } from '../adapters/OptionsAdapter.ts'; import type { OptionChainRow, OptionGreeks } from '../adapters/OptionsAdapter.ts'; 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.' }); 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,created_at) VALUES (?,?,?,?)').run(userId, email, hashPassword(input.password), new Date().toISOString()); const { cookie } = createSession(ctx.db, userId); ctx.resHeaders.append('Set-Cookie', cookie); return { userId }; }), 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 FROM users WHERE email=?').get(email) as { id: string; pw_hash: string; is_2fa_enabled: number; totp_secret: string | null } | undefined; if (!row || !verifyPassword(input.password, row.pw_hash)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid email or password.' }); 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 FROM users WHERE id=?').get(ctx.userId) as { id: string; email: string; complexity: string; risk_tolerance: string; convexity_posture: string } | undefined; const wl = ctx.db.prepare('SELECT 1 FROM watchlists WHERE owner_id=? LIMIT 1').get(ctx.userId); return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture, onboarded: !!wl } : 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: the starter watchlist + disclaimer shown in the wizard before completing. starter: publicProcedure.query(() => ({ watchlist: STARTER_WATCHLIST, disclaimer: ONBOARDING_DISCLAIMER })), // Protected: write complexity/risk/drawdown + first watchlist + optional portfolio; subscribe symbols to demand. complete: protectedProcedure .input(z.object({ complexity: z.enum(['beginner', 'intermediate', 'advanced']), 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 complexity = input.complexity as Complexity; const riskTolerance = input.riskTolerance ?? defaultRiskTolerance(complexity); const drawdown = input.drawdownTolerancePct ?? defaultDrawdownTolerancePct(complexity); ctx.db.prepare('UPDATE users SET complexity=?, risk_tolerance=?, drawdown_tolerance=? WHERE id=?').run(complexity, riskTolerance, drawdown, userId); const symbols = input.firstWatchlistSymbols ?? STARTER_WATCHLIST.map((s) => s.symbol); const wlId = randomUUID(); ctx.db.prepare('INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?,?,?,?,?,?)').run(wlId, userId, 'Starter', JSON.stringify(symbols), new Date().toISOString(), 0); for (const sym of symbols) { const kind = (STARTER_WATCHLIST.find((s) => s.symbol === sym)?.tickerKind ?? 'equity') as 'equity' | 'crypto' | 'etf' | 'index'; await ctx.cache.subscribe(sym, kind); queueSecFetch(ctx.db, sym); } 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 }; }), }); 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) }, }; }), candles: publicProcedure .input(z.object({ symbol: z.string().min(1), timeframe: z.enum(['1d', '1wk']).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']).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 } as const)), })) .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); } return { ema: emaObj, rsi: rsiFn(closes, periods.rsi), relativeVolume: relativeVolume(volumes, periods.relvol), }; }), }); // --------------------------------------------------------------------------- // admin.* (Slice 25) — operator tooling, auth-gated to is_admin=1. // --------------------------------------------------------------------------- const adminRouter = router({ usersList: adminProcedure.query(({ ctx }) => listUsers(ctx.db)), 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)), 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.' }); } }), queueStatus: adminProcedure.query(({ ctx }) => ctx.queue.health()), 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 }; }), }); // 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 }; }), }); // ─── 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 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; }>; 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) { // Sort by filed_at descending const sorted = [...cikRows].sort((a, b) => b.filed_at.localeCompare(a.filed_at)); if (sorted.length < 2) continue; const curr = sorted[0]; const prev = sorted[1]; 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: curr.reported_quarter, }); } 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, })); return { symbol, flow: allFlow, quarters, filings }; }), /** 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, }; }), }); // ─── 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; }>; 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. */ form13f_holdings: 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.form13f_holdings(input.cik, input.accession); return { holdings: result.value as Form13fHoldingsResult }; } 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 in the user's default watchlist. */ list: publicProcedure .query(async ({ ctx }) => { const userId = ctx.userId ?? 'anonymous'; const { listSymbols } = await import('../db/watchlistRepository.ts'); return listSymbols(ctx.db, userId); }), /** Add a symbol to the default watchlist. */ addSymbol: publicProcedure .input(z.object({ symbol: z.string().toUpperCase() })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { addSymbol } = await import('../db/watchlistRepository.ts'); const added = addSymbol(ctx.db, userId, input.symbol); if (added) { queueSecFetch(ctx.db, input.symbol); } return { added }; }), /** Remove a symbol from the default watchlist. */ removeSymbol: publicProcedure .input(z.object({ symbol: z.string().toUpperCase() })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId ?? 'anonymous'; const { removeSymbol } = await import('../db/watchlistRepository.ts'); const removed = removeSymbol(ctx.db, userId, input.symbol); return { removed }; }), }); // ─── 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: protectedProcedure .input(z.object({ symbol: z.string().toUpperCase(), shares: z.number().positive(), avgCost: z.number().min(0) })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const { addHolding } = await import('../db/portfolioRepository.ts'); const created = addHolding(ctx.db, userId, input.symbol, input.shares, input.avgCost); return { created }; }), /** Remove (close) a holding. */ removeHolding: protectedProcedure .input(z.object({ symbol: z.string().toUpperCase() })) .mutation(async ({ ctx, input }) => { const userId = ctx.userId as string; const { removeHolding } = await import('../db/portfolioRepository.ts'); const removed = removeHolding(ctx.db, userId, input.symbol); 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 }; } }), }); 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.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 }; }), }); 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[]); }), }); // ─── 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); }), }); // ─── Options Convexity Router (Slice 19) ──────────────────────────────────── const optionsConvexityRouter = router({ unlock: protectedProcedure .input(z.object({ fromState: z.number().int().min(0).max(4), toState: z.number().int().min(0).max(4) })) .mutation(async ({ ctx, input }) => { const { canElevate } = await import('../options/ConvexityGate.ts'); const userId = ctx.userId as string; const row = ctx.db.prepare('SELECT state, understanding FROM options_unlock WHERE user_id=?').get(userId) as { state: number; understanding: number } | undefined; const currentState = (row?.state ?? 0); const hasUnderstanding = (row?.understanding ?? 0) === 1; const result = canElevate(currentState, input.toState, hasUnderstanding, true, input.toState >= 3, input.toState === 4); if (!result.allowed) throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); ctx.db.prepare('INSERT OR REPLACE INTO options_unlock (user_id, state, last_unlock_at, understanding) VALUES (?,?,?,?)').run(userId, input.toState, new Date().toISOString(), row?.understanding ?? 0); return { state: input.toState, reason: result.reason }; }), getPayoff: protectedProcedure .input(z.object({ strike: z.number(), premium: z.number(), right: z.enum(['call', 'put']), underlyingPrice: z.number() })) .query(async ({ ctx, input }) => { const { computePayoffDiagram } = await import('../options/ConvexityGate.ts'); return computePayoffDiagram(input.strike, input.premium, input.right, input.underlyingPrice); }), }); // ─── 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(), optionsUnlockState: z.number().optional(), profitTargets: z.array(z.number()).optional(), })) .query(async ({ ctx, input }) => { const { suggestDerisking } = await import('../derisking/DeriskingEngine.ts'); return { suggestions: suggestDerisking(input) }; }), }); // ─── 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'); // Gather macro inputs from cache const input: Record = {}; 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; } 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; } return classifyRegime(input); }), commentary: protectedProcedure.query(async ({ ctx }) => { const { classifyRegime, generateMacroCommentary } = await import('../macro/MacroRegime.ts'); const input: Record = {}; 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; } 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({ cashtag_search: protectedProcedure .input(z.object({ cashtag: z.string(), limit: z.number().min(1).max(100).optional().default(20) })) .query(async ({ ctx, input }) => { const { XCookieAdapter } = await import('../adapters/XCookieAdapter.ts'); const adapter = new XCookieAdapter(); const key = `x:cashtag:${input.cashtag}:${input.limit}`; const entry = await ctx.cache.get(key); if (entry.value) return { posts: entry.value, isStale: entry.isStale }; try { const result = await adapter.cashtag_search(input.cashtag, input.limit); return { posts: result.value, isStale: false }; } catch (e) { throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message }); } }), timeline: protectedProcedure .input(z.object({ account: z.string(), limit: z.number().min(1).max(100).optional().default(20) })) .query(async ({ ctx, input }) => { const { XCookieAdapter } = await import('../adapters/XCookieAdapter.ts'); const adapter = new XCookieAdapter(); try { const result = await adapter.trusted_account_timeline(input.account, input.limit); return { posts: result.value, isStale: false }; } catch (e) { throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message }); } }), }); 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.subreddit(input.subreddit, input.limit); 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.search(input.q, input.limit); return { posts: result.value, isStale: false }; } catch (e) { throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message }); } }), }); // --------------------------------------------------------------------------- // 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 }); } }), }); 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, reports: reportsRouter, screener: screenerRouter, strategies: strategyRouter, backtest: backtestRouter, sectorCrosslink: sectorCrosslinkRouter, optionsConvexity: optionsConvexityRouter, derisking: deriskingRouter, macro: macroRouter, thesisMonitor: thesisMonitorRouter, x: xRouter, reddit: redditRouter, emotionLogger: emotionLoggerRouter, }); export type AppRouter = typeof appRouter;