- 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>
1291 lines
57 KiB
TypeScript
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;
|
|
|