Files
investor-flow/app/server/src/trpc/router.ts
T
Investor Flow BuildandClaude ca385c1960 feat(queue): adapter queue overhaul — pause/resume, error capture, scheduling
- Pause/resume persisted in queue_state; drain loop checks isPaused() and skips
- Error capture: every failed/backoff attempt logged to queue_errors with the
  full stack trace; admin queue page expands a failed job to stream it
- Retry controls: retryJob(key), retrySource(kind), clearDone(olderThanMs)
- Per-source scheduling: queue_schedules table + 30s enqueueDueSchedules loop
  (seed defaults sec-fetch 24h, yfinance 5min); admin UI lists/adds/deletes
- Startup recovery: interrupted in_flight jobs reset to pending on boot
- fix(edgar): archive URLs use the filer CIK (accession-number prefix), not the
  company CIK — resolves Cloudflare 429 that left SEC backfills sparse/empty
- Migration: add queue_errors, queue_schedules, queue_state tables plus error/
  scheduled_for columns on adapter_queue (idempotent ALTER on startup)

Verified: full sec-fetch backfill now succeeds for all watched symbols
(NVDA 14,675 institution filings, CIFR 274 insider txns, TSLA 6,011, etc.).
503 backend tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 12:55:59 -04:00

1291 lines
57 KiB
TypeScript

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<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<unknown>([k.quote, k.candles, k.sector]);
const byKey = new Map(entries.map((e) => [e.key, e]));
const val = <T>(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<Quote>(k.quote),
candles: val<PriceCandle[]>(k.candles),
sector: val<SymbolMeta>(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<PriceCandle[]>(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<PriceCandle[]>(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<string, (number | undefined)[]> = {};
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<Record<string, unknown>>;
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<string, typeof rows>();
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<string, unknown>, 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<Record<string, unknown>> };
} 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<string, unknown>,
});
}),
});
// ─── 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<unknown>(`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<unknown>(`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<unknown[]>(`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<unknown[]>(`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<Record<string, unknown>>;
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<unknown[]>('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<string, number> = {};
const gdpEntry = await ctx.cache.get<unknown>('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<unknown>('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<string, number> = {};
const gdpEntry = await ctx.cache.get<unknown>('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<Record<string, unknown>>;
// 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<unknown>(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;