Files
investor-flow/app/server/src/trpc/router.ts
T

3089 lines
135 KiB
TypeScript
Raw Normal View History

import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { SQLInputValue } from 'node:sqlite';
import { randomUUID } from 'node:crypto';
import type { Context } from './context.ts';
import { hashPassword, verifyPassword, createSession, clearCookie, oauthStateCookie, verifyOAuthState, OAUTH_STATE_COOKIE } from './context.ts';
import { generateBase32Secret, totp as computeTotp, verifyTotp, otpauthUrl } from '../auth/totp.ts';
import { generateBackupCodes, hashBackupCode } from '../auth/backup-codes.ts';
import { buildAuthorizeUrl, generateState, exchangeCode, type OAuthProvider } from '../auth/oauth.ts';
import { STARTER_WATCHLIST, defaultDrawdownTolerancePct, defaultRiskTolerance, ONBOARDING_DISCLAIMER, type Complexity } from '../onboarding/starter.ts';
import type { Quote, PriceCandle, SymbolMeta } from '../cache/CacheRepository.ts';
import type { SymbolUniverseData } from '../screener/UniverseEvaluator.ts';
import type { ThesisEvent } from '../thesis/ThesisMonitor.ts';
import type { OptionsUnlockState } from '../options/ConvexityGate.ts';
import type { XCookieHealth } from '../adapters/XCookieAdapter.ts';
import { emaFromCandles, rsi as rsiFn, relativeVolume, macd as macdFn } from '../analysis/indicators.ts';
import { listUsers, resetPassword, gdprExport, queueHealth, resetQueueBackoff, NotOwnerError, listUserSessions, listAuditLog, queueSecFetch, setUserModules, disableUser, enableUser, deleteUser } from '../admin/admin.ts';
import { restartServers, type RestartTarget } from '../lib/restart.ts';
import type { LintResult } from '../services/secDataFetcher.ts';
import { EdgarAdapter } from '../adapters/EdgarAdapter.ts';
import { OptionsAdapter, parseOptionChainRows } from '../adapters/OptionsAdapter.ts';
import type { OptionChainRow, OptionGreeks } from '../adapters/OptionsAdapter.ts';
// ---------------------------------------------------------------------------
// X cookie credential helpers. Loads AES-256-GCM encrypted ct0/auth_token from
// DB and decrypts them per-request. Returns null when not configured.
// ---------------------------------------------------------------------------
type XCredentials = { ct0: string; auth_token: string };
async function loadXCredentials(ctx: Context): Promise<XCredentials | null> {
const row = ctx.db.prepare('SELECT ct0_enc, auth_token_enc FROM x_credentials WHERE id=?').get('singleton') as { ct0_enc?: string | null; auth_token_enc?: string | null } | undefined;
if (!row || !row.ct0_enc || !row.auth_token_enc) return null;
const encryptMod = await import('../lib/crypto.ts');
try {
return { ct0: encryptMod.default.decrypt(row.ct0_enc), auth_token: encryptMod.default.decrypt(row.auth_token_enc) };
} catch { return null; } // decrypt failure — treat as unconfigured.
}
function updateXHealth(db: any, status: 'healthy' | 'degraded' | 'failed', err?: string): void {
try {
db.prepare(
`INSERT INTO x_credentials (id, healthy, last_error, updated_at) VALUES ('singleton', ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET healthy=excluded.healthy, last_error=excluded.last_error, updated_at=excluded.updated_at`
).run(status === 'healthy' ? 1 : 0, err ?? null, new Date().toISOString());
} catch { /* ignore — health tracking is best-effort */ }
}
const t = initTRPC.context<Context>().create();
const router = t.router;
const publicProcedure = t.procedure;
const protectedProcedure = publicProcedure.use(({ ctx, next }) => {
if (!ctx.userId) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Sign in required.' });
const row = ctx.db.prepare('SELECT status FROM users WHERE id=?').get(ctx.userId) as { status: string } | undefined;
if (!row) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User not found.' });
if (row.status !== 'active') throw new TRPCError({ code: 'FORBIDDEN', message: row.status === 'pending_approval' ? 'Account pending admin approval.' : 'Account disabled.' });
return next({ ctx });
});
// Admin-guarded procedures (Slice 25). Non-admin/anonymous → 403.
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
const row = ctx.db.prepare('SELECT is_admin FROM users WHERE id=?').get(ctx.userId!) as { is_admin: number | null } | undefined;
if (!row || !row.is_admin) throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required.' });
return next({ ctx });
});
function oauthCreds(provider: OAuthProvider): { clientId?: string; clientSecret?: string } {
if (provider === 'github') return { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET };
return { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET };
}
const authRouter = router({
signup: publicProcedure
.input(z.object({ email: z.string().email(), password: z.string().min(8) }))
.mutation(async ({ ctx, input }) => {
const email = input.email.toLowerCase();
const existing = ctx.db.prepare('SELECT id FROM users WHERE email=?').get(email);
if (existing) throw new TRPCError({ code: 'CONFLICT', message: 'That email is already registered.' });
const userId = randomUUID();
ctx.db.prepare('INSERT INTO users (id,email,pw_hash,status,created_at) VALUES (?,?,?,?,?)').run(userId, email, hashPassword(input.password), 'pending_approval', new Date().toISOString());
return { userId, pending: true, message: 'Account created. An admin must approve your account before you can sign in.' };
}),
login: publicProcedure
.input(z.object({ email: z.string().email(), password: z.string(), totp: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
const email = input.email.toLowerCase();
const row = ctx.db.prepare('SELECT id, pw_hash, is_2fa_enabled, totp_secret, status FROM users WHERE email=?').get(email) as { id: string; pw_hash: string; is_2fa_enabled: number; totp_secret: string | null; status: string } | undefined;
if (!row || !verifyPassword(input.password, row.pw_hash)) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid email or password.' });
if (row.status !== 'active') throw new TRPCError({ code: 'FORBIDDEN', message: row.status === 'pending_approval' ? 'Account pending admin approval.' : 'Account disabled.' });
if (row.is_2fa_enabled === 1) {
if (!input.totp || !row.totp_secret || !verifyTotp(input.totp, row.totp_secret)) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Two-factor code required or invalid.' });
}
}
const { cookie } = createSession(ctx.db, row.id);
ctx.resHeaders.append('Set-Cookie', cookie);
return { userId: row.id };
}),
logout: publicProcedure.mutation(({ ctx }) => {
ctx.resHeaders.append('Set-Cookie', clearCookie());
return { ok: true };
}),
me: publicProcedure.query(({ ctx }) => {
if (!ctx.userId) return null;
const u = ctx.db.prepare('SELECT id,email,complexity,risk_tolerance,convexity_posture,modules FROM users WHERE id=?').get(ctx.userId) as { id: string; email: string; complexity: string; risk_tolerance: string; convexity_posture: string; modules: string } | undefined;
const wl = ctx.db.prepare('SELECT 1 FROM watchlists WHERE owner_id=? LIMIT 1').get(ctx.userId);
let modules: string[] = ['research', 'settings'];
try { modules = JSON.parse(u?.modules ?? '["research","settings"]'); } catch { /* keep default */ }
if (u?.id) {
const adminRow = ctx.db.prepare('SELECT is_admin FROM users WHERE id=?').get(u.id) as { is_admin: number } | undefined;
if (adminRow?.is_admin && !modules.includes('admin')) modules.push('admin');
}
return u ? { userId: u.id, email: u.email, complexity: u.complexity, riskTolerance: u.risk_tolerance, convexityPosture: u.convexity_posture, onboarded: !!wl, modules } : 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, 'default', 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);
}
try {
const { materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts');
materializeClassificationWatchlists(ctx.db, userId);
} catch { /* ignore — sector data not available yet, will materialize on first listWatchlists */ }
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) },
};
}),
/**
* Focused-ticker context: performance vs market, sector/theme proxies, and peers.
* Educational relative snapshot (ADR-0007).
*/
tickerContext: publicProcedure
.input(z.object({ symbol: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const {
returnsBundle,
buildTickerContext,
relativeTo,
resolveBusinessContext,
} = await import('../analysis/tickerContext.ts');
const {
MARKET_ROTATION_UNIVERSE,
BENCHMARK_SYMBOL,
buildSectorRsMap,
} = await import('../analysis/marketRotationRs.ts');
const { classifyRegime } = await import('../macro/MacroRegime.ts');
const symbol = input.symbol.toUpperCase();
try { await ctx.cache.subscribe(symbol, 'equity'); } catch { /* ignore */ }
try { await ctx.cache.subscribe(BENCHMARK_SYMBOL, 'etf'); } catch { /* ignore */ }
const metaEntry = await ctx.cache.get<SymbolMeta>(`yfinance:symbol:${symbol}`);
const meta = metaEntry.value;
const name = meta?.name ?? null;
const sectorName = meta?.sector ?? null;
const industry = meta?.industry ?? null;
const tickerKind = meta?.tickerKind ?? 'equity';
const description = meta?.description ?? null;
// Prefer operating-profile resolution over raw Yahoo GICS (fixes IREN→XLF etc.).
const biz = resolveBusinessContext({
symbol,
sector: sectorName,
industry,
description,
});
const sectorMap = biz.sectorEtf ? { etf: biz.sectorEtf, label: biz.sectorLabel ?? biz.sectorEtf } : null;
const themeMap = biz.themeEtf ? { etf: biz.themeEtf, label: biz.themeLabel ?? biz.themeEtf } : null;
// Peers: curated override first, then vendor peers, then sector-ETF holdings (never when blocked).
let peerSymbols: string[] = biz.peers.length > 0
? biz.peers.filter((p) => p !== symbol)
: Array.isArray(meta?.peers)
? (meta!.peers as string[]).map((p) => String(p).toUpperCase()).filter((p) => p && p !== symbol)
: [];
if (peerSymbols.length === 0 && sectorMap && !biz.blockSectorEtfPeers) {
try {
const mod = await import('yahoo-finance2');
const yf = new mod.default();
const summary = await yf.quoteSummary(sectorMap.etf, { modules: ['topHoldings'] }) as {
topHoldings?: { holdings?: Array<{ symbol?: string }> };
};
peerSymbols = (summary?.topHoldings?.holdings ?? [])
.map((h) => (h.symbol ?? '').toUpperCase())
.filter((p) => p && p !== symbol)
.slice(0, 8);
} catch { /* live peer pull optional */ }
}
const symbolsToLoad = Array.from(new Set([
symbol,
BENCHMARK_SYMBOL,
...(sectorMap ? [sectorMap.etf] : []),
...(themeMap ? [themeMap.etf] : []),
...peerSymbols.slice(0, 8),
]));
for (const s of symbolsToLoad) {
try { await ctx.cache.subscribe(s, s === BENCHMARK_SYMBOL || s.length <= 4 && MARKET_ROTATION_UNIVERSE.some((u) => u.symbol === s) ? 'etf' : 'equity'); } catch { /* ignore */ }
}
const candleKeys = symbolsToLoad.map((s) => `yfinance:candles:${s}:1d`);
const candleEntries = await ctx.cache.getMany<PriceCandle[]>(candleKeys);
const candlesBySym = new Map<string, PriceCandle[]>();
symbolsToLoad.forEach((s, i) => {
candlesBySym.set(s, (candleEntries[i]?.value ?? []) as PriceCandle[]);
});
const toPts = (arr: PriceCandle[]) => arr.map((c) => ({ ts: c.ts, c: c.c, v: c.v }));
const symbolReturns = returnsBundle(toPts(candlesBySym.get(symbol) ?? []));
const marketReturns = returnsBundle(toPts(candlesBySym.get(BENCHMARK_SYMBOL) ?? []));
const sectorReturns = sectorMap
? returnsBundle(toPts(candlesBySym.get(sectorMap.etf) ?? []))
: null;
const themeReturns = themeMap
? returnsBundle(toPts(candlesBySym.get(themeMap.etf) ?? []))
: null;
// Sector leadership from RS map when possible.
let sectorLeadership: 'leading' | 'lagging' | 'inline' | 'unknown' | null = null;
try {
const rotSymbols = [BENCHMARK_SYMBOL, ...MARKET_ROTATION_UNIVERSE.map((u) => u.symbol)];
const rotKeys = rotSymbols.map((s) => `yfinance:candles:${s}:1d`);
const rotEntries = await ctx.cache.getMany<PriceCandle[]>(rotKeys);
const map: Record<string, PriceCandle[]> = {};
rotSymbols.forEach((s, i) => { map[s] = (rotEntries[i]?.value ?? []) as PriceCandle[]; });
const rows = buildSectorRsMap(MARKET_ROTATION_UNIVERSE, map, map[BENCHMARK_SYMBOL] ?? []);
if (sectorMap) {
const row = rows.find((r) => r.symbol === sectorMap.etf);
sectorLeadership = row?.leadership ?? null;
}
} catch { /* optional */ }
// Market regime from available FRED/SPY factors (same soft path as condition).
let marketRegime: 'trending-up' | 'trending-down' | 'range-bound' | null = null;
let marketRegimeConfidence: number | null = null;
try {
const regimeInput: Record<string, number> = {};
const spy = candlesBySym.get(BENCHMARK_SYMBOL) ?? [];
if (spy.length >= 50) {
const last = spy[spy.length - 1].c;
const ago = spy[spy.length - 50].c;
if (ago > 0) regimeInput.spyEmaSlope = ((last - ago) / ago) * 100;
}
try {
const vixEntry = await ctx.cache.get<Quote>('yfinance:quote:^VIX');
if (vixEntry?.value?.price != null) regimeInput.vix = vixEntry.value.price;
} catch { /* ignore */ }
const classification = classifyRegime(regimeInput);
marketRegime = classification.regime;
marketRegimeConfidence = classification.confidence;
} catch { /* ignore */ }
const peers = peerSymbols.slice(0, 8).map((p) => {
const ret = returnsBundle(toPts(candlesBySym.get(p) ?? []));
return {
symbol: p,
name: null as string | null,
returns: ret,
rsVsMarket1M: relativeTo(ret.oneMonth, marketReturns.oneMonth),
};
});
// Optional peer names from symbol meta cache.
for (const p of peers) {
try {
const pe = await ctx.cache.get<SymbolMeta>(`yfinance:symbol:${p.symbol}`);
if (pe.value?.name) p.name = pe.value.name;
} catch { /* ignore */ }
}
return buildTickerContext({
symbol,
name,
sector: sectorName,
industry,
tickerKind,
symbolReturns,
marketReturns,
marketRegime,
marketRegimeConfidence,
sectorEtf: sectorMap?.etf ?? null,
sectorLabel: sectorMap?.label ?? sectorName,
sectorReturns,
sectorLeadership,
themeEtf: themeMap?.etf ?? null,
themeLabel: themeMap?.label ?? null,
themeReturns,
peers,
classificationNote: biz.classificationNote,
vendorSector: biz.vendorSector,
vendorIndustry: biz.vendorIndustry,
});
}),
candles: publicProcedure
.input(z.object({ symbol: z.string().min(1), timeframe: z.enum(['1d', '1wk', '1mo']).default('1d') }))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const key = `yfinance:candles:${symbol}:${input.timeframe}`;
const entry = await ctx.cache.get<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', '1mo']).default('1d'),
periods: z.object({
ema: z.array(z.number().int()).default([9, 21, 50, 200]),
rsi: z.number().int().default(14),
relvol: z.number().int().default(20),
}).default(() => ({ ema: [9, 21, 50, 200], rsi: 14, relvol: 20 })),
}))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const key = `yfinance:candles:${symbol}:${input.timeframe}`;
const entry = await ctx.cache.get<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);
}
const macdResult = macdFn(closes);
return {
ema: emaObj,
rsi: rsiFn(closes, periods.rsi),
relvol: relativeVolume(volumes, periods.relvol),
macd: macdResult,
};
}),
truckSales: publicProcedure.query(async ({ ctx }) => {
const row = ctx.db.prepare('SELECT fred_api_key_enc FROM x_credentials WHERE id=?').get('singleton') as { fred_api_key_enc?: string | null } | undefined;
let apiKey = '';
if (row?.fred_api_key_enc) {
const { default: encrypt } = await import('../lib/crypto.ts');
try { apiKey = encrypt.decrypt(row.fred_api_key_enc); } catch { /* key corrupt */ }
}
if (!apiKey) {
return { observations: [], seriesTitle: 'Heavy Trucks Sold, Monthly', units: '', configured: false, error: 'FRED API key not configured' };
}
try {
const { FredAdapterImpl, FRED_SERIES } = await import('../macro/FredAdapter.ts');
const adapter = new FredAdapterImpl(apiKey);
const entry = await adapter.series(FRED_SERIES.HEAVY_TRUCK_SALES);
const series = entry.value as { seriesId: string; title: string; units: string; observations: Array<{ date: string; value: number }> };
return { observations: series.observations, seriesTitle: series.title, units: series.units, configured: true };
} catch (e) {
return { observations: [], seriesTitle: 'Heavy Trucks Sold, Monthly', units: '', configured: true, error: (e as Error).message };
}
}),
manufacturingPmi: publicProcedure.query(async ({ ctx }) => {
const row = ctx.db.prepare('SELECT fred_api_key_enc FROM x_credentials WHERE id=?').get('singleton') as { fred_api_key_enc?: string | null } | undefined;
let apiKey = '';
if (row?.fred_api_key_enc) {
const { default: encrypt } = await import('../lib/crypto.ts');
try { apiKey = encrypt.decrypt(row.fred_api_key_enc); } catch { /* key corrupt */ }
}
if (!apiKey) {
return { observations: [], seriesTitle: 'Industrial Production: Manufacturing', units: '', configured: false, error: 'FRED API key not configured' };
}
try {
const { FredAdapterImpl, FRED_SERIES } = await import('../macro/FredAdapter.ts');
const adapter = new FredAdapterImpl(apiKey);
const entry = await adapter.series(FRED_SERIES.MANUFACTURING_ACTIVITY);
const series = entry.value as { seriesId: string; title: string; units: string; observations: Array<{ date: string; value: number }> };
return { observations: series.observations, seriesTitle: series.title, units: series.units, configured: true };
} catch (e) {
return { observations: [], seriesTitle: 'Industrial Production: Manufacturing', units: '', configured: true, error: (e as Error).message };
}
}),
/**
* Market Condition strip — regime + risk flags.
* FRED series are fetched live (key from admin) when not already warm; SPY/VIX from yfinance.
* Educational snapshot only (ADR-0007).
*/
condition: publicProcedure.query(async ({ ctx }) => {
const { classifyRegime } = await import('../macro/MacroRegime.ts');
const { totalReturnPct } = await import('../analysis/marketRotationRs.ts');
const { FredAdapterImpl, FRED_SERIES } = await import('../macro/FredAdapter.ts');
const factors: Record<string, number | null> = {
gdpGrowth: null,
cpi: null,
unemployment: null,
treasury10Y: null,
treasury2Y: null,
curve10y2y: null,
vix: null,
spy1M: null,
spyEmaSlope: null,
};
// Decrypt FRED key (same store as truck sales / manufacturing).
let fredKey = '';
try {
const row = ctx.db.prepare('SELECT fred_api_key_enc FROM x_credentials WHERE id=?').get('singleton') as { fred_api_key_enc?: string | null } | undefined;
if (row?.fred_api_key_enc) {
const { default: encrypt } = await import('../lib/crypto.ts');
try { fredKey = encrypt.decrypt(row.fred_api_key_enc); } catch { /* corrupt */ }
}
} catch { /* ignore */ }
type FredObs = Array<{ date: string; value: number }>;
/** FRED observations: kv_cache first, then live API; write-through to kv_cache. */
async function fredObs(seriesId: string): Promise<FredObs | null> {
const cacheKey = `fred:series:${seriesId}`;
try {
const row = ctx.db.prepare('SELECT value, observed_at FROM kv_cache WHERE key=?').get(cacheKey) as
| { value: string; observed_at: string } | undefined;
if (row?.value) {
const age = Date.now() - Date.parse(row.observed_at);
const parsed = JSON.parse(row.value) as { observations?: FredObs };
const obs = parsed.observations;
if (obs?.length && Number.isFinite(age) && age < 24 * 60 * 60_000) {
return obs;
}
}
} catch { /* miss */ }
if (!fredKey) return null;
try {
const adapter = new FredAdapterImpl(fredKey);
const entry = await adapter.series(seriesId);
const series = entry.value as { observations?: FredObs } | null;
const obs = series?.observations;
if (!obs?.length) return null;
const now = new Date().toISOString();
try {
ctx.db.prepare(
'INSERT OR REPLACE INTO kv_cache (key, value, observed_at) VALUES (?,?,?)',
).run(cacheKey, JSON.stringify({ seriesId, observations: obs }), now);
} catch { /* best-effort cache; may race under SQLite */ }
return obs;
} catch {
return null;
}
}
function lastValue(obs: FredObs | null): number | null {
if (!obs?.length) return null;
const v = obs[obs.length - 1].value;
return Number.isFinite(v) ? v : null;
}
/** YoY % from monthly index series (needs ~13 observations). */
function yoyPct(obs: FredObs | null): number | null {
if (!obs || obs.length < 13) return lastValue(obs);
const last = obs[obs.length - 1].value;
const yearAgo = obs[obs.length - 13].value;
if (!yearAgo || yearAgo === 0) return null;
return ((last - yearAgo) / yearAgo) * 100;
}
// Parallel FRED pulls (rates, labor, inflation).
const [unrateObs, gs10Obs, gs2Obs, cpiObs] = await Promise.all([
fredObs(FRED_SERIES.UNEMPLOYMENT),
fredObs(FRED_SERIES.TREASURY_10Y),
fredObs(FRED_SERIES.TREASURY_2Y),
fredObs(FRED_SERIES.CPI),
]);
factors.unemployment = lastValue(unrateObs);
factors.treasury10Y = lastValue(gs10Obs);
factors.treasury2Y = lastValue(gs2Obs);
factors.cpi = yoyPct(cpiObs); // classifier expects inflation %, not CPI index level
if (factors.treasury10Y !== null && factors.treasury2Y !== null) {
factors.curve10y2y = factors.treasury10Y - factors.treasury2Y;
}
// SPY trend + VIX from yfinance (subscribe if cold).
try {
try { await ctx.cache.subscribe('SPY', 'etf'); } catch { /* ignore */ }
const spyEntry = await ctx.cache.get<PriceCandle[]>('yfinance:candles:SPY:1d');
const spy = (spyEntry?.value ?? []) as PriceCandle[];
factors.spy1M = totalReturnPct(spy, 30 * 86_400_000);
if (spy.length >= 60) {
const last = spy[spy.length - 1].c;
const ago = spy[spy.length - 50].c;
if (ago > 0) factors.spyEmaSlope = ((last - ago) / ago) * 100;
}
} catch { /* ignore */ }
try {
const vixEntry = await ctx.cache.get<Quote>('yfinance:quote:^VIX');
if (vixEntry?.value?.price != null) factors.vix = vixEntry.value.price;
else {
const vixC = await ctx.cache.get<PriceCandle[]>('yfinance:candles:^VIX:1d');
const arr = vixC?.value as PriceCandle[] | undefined;
if (arr?.length) factors.vix = arr[arr.length - 1].c;
}
// Live fallback via yahoo-finance2 if still missing.
if (factors.vix == null) {
try {
const mod = await import('yahoo-finance2');
const yf = new mod.default();
const q = await yf.quote('^VIX') as { regularMarketPrice?: number };
if (typeof q?.regularMarketPrice === 'number') factors.vix = q.regularMarketPrice;
} catch { /* ignore */ }
}
} catch { /* ignore */ }
// Yield fallback from Yahoo if FRED key missing/failed (^TNX ≈ 10Y yield).
if (factors.treasury10Y == null) {
try {
const mod = await import('yahoo-finance2');
const yf = new mod.default();
const q = await yf.quote('^TNX') as { regularMarketPrice?: number };
if (typeof q?.regularMarketPrice === 'number') factors.treasury10Y = q.regularMarketPrice;
} catch { /* ignore */ }
}
const classification = classifyRegime({
gdpGrowth: factors.gdpGrowth ?? undefined,
cpi: factors.cpi ?? undefined,
unemployment: factors.unemployment ?? undefined,
treasury10Y: factors.treasury10Y ?? undefined,
vix: factors.vix ?? undefined,
spyEmaSlope: factors.spyEmaSlope ?? undefined,
});
type RiskFlag = { id: string; label: string; severity: 'info' | 'warning' | 'elevated'; detail: string };
const risks: RiskFlag[] = [];
if (factors.curve10y2y !== null && factors.curve10y2y < 0) {
risks.push({
id: 'curve_inverted',
label: 'Yield curve inverted',
severity: 'warning',
detail: `10Y−2Y is ${factors.curve10y2y.toFixed(2)} pp. Curve inversions have historically preceded slower growth — a risk flag, not a timer.`,
});
}
if (factors.vix !== null && factors.vix >= 25) {
risks.push({
id: 'vol_elevated',
label: 'Elevated volatility',
severity: factors.vix >= 30 ? 'elevated' : 'warning',
detail: `VIX near ${factors.vix.toFixed(1)}. Higher implied fear often coincides with wider daily ranges; sizing frameworks typically reduce risk fractions in this environment.`,
});
}
if (factors.spy1M !== null && factors.spy1M <= -8) {
risks.push({
id: 'spy_drawdown',
label: 'Broad market soft (1M)',
severity: 'warning',
detail: `SPY about ${factors.spy1M.toFixed(1)}% over the last month. Review drawdown tolerance and thesis integrity before adding risk.`,
});
}
if (factors.unemployment !== null && factors.unemployment >= 5.5) {
risks.push({
id: 'labor_soft',
label: 'Labor market softer',
severity: 'info',
detail: `Unemployment near ${factors.unemployment.toFixed(1)}%. Track whether the trend is rising or stabilizing.`,
});
}
if (risks.length === 0) {
risks.push({
id: 'no_flags',
label: 'No elevated flags from available data',
severity: 'info',
detail: 'Available series do not show an inverted curve, elevated VIX, or a sharp one-month broad-market drawdown. Missing data is not the same as absence of risk.',
});
}
const regimePlain =
classification.regime === 'trending-up' ? 'Trending up'
: classification.regime === 'trending-down' ? 'Trending down'
: 'Range-bound';
return {
regime: classification.regime,
regimePlain,
confidence: classification.confidence,
factors: classification.factors,
explanation: classification.explanation,
metrics: {
spy1M: factors.spy1M,
vix: factors.vix,
treasury10Y: factors.treasury10Y,
treasury2Y: factors.treasury2Y,
curve10y2y: factors.curve10y2y,
unemployment: factors.unemployment,
},
risks,
updatedAt: new Date().toISOString(),
lesson: 'Regime and risk flags summarize current data. They provide process context — not forecasts or allocation instructions.',
};
}),
rotationCheckForAlert: publicProcedure.mutation(async ({ ctx }) => {
const {
MARKET_ROTATION_UNIVERSE,
BENCHMARK_SYMBOL,
buildSectorRsMap,
summarizeRotation,
} = await import('../analysis/marketRotationRs.ts');
const symbols = [BENCHMARK_SYMBOL, ...MARKET_ROTATION_UNIVERSE.map((s) => s.symbol)];
for (const sym of symbols) {
try { await ctx.cache.subscribe(sym, 'etf'); } catch { /* ignore */ }
}
const keys = symbols.map((s) => `yfinance:candles:${s}:1d`);
const entries = await ctx.cache.getMany<PriceCandle[]>(keys);
const map: Record<string, PriceCandle[]> = {};
symbols.forEach((s, i) => {
map[s] = (entries[i]?.value ?? []) as PriceCandle[];
});
const rows = buildSectorRsMap(
MARKET_ROTATION_UNIVERSE,
map,
map[BENCHMARK_SYMBOL] ?? [],
);
const summary = summarizeRotation(rows);
const signal = summary.strength;
const spread = summary.leadershipSpread;
const prev = ctx.db.prepare('SELECT signal, checked_at, signal_since FROM rotation_state WHERE id=?').get('singleton') as { signal: string; checked_at: string | null; signal_since: string | null } | undefined;
const now = new Date().toISOString();
const signalSince = prev?.signal === signal ? (prev.signal_since ?? now) : now;
ctx.db.prepare(
`INSERT INTO rotation_state (id, signal, spread, inflow_avg, outflow_avg, checked_at, signal_since)
VALUES ('singleton', ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET signal=excluded.signal, spread=excluded.spread, inflow_avg=excluded.inflow_avg, outflow_avg=excluded.outflow_avg, checked_at=excluded.checked_at, signal_since=excluded.signal_since`
).run(signal, spread, summary.leadingCount, summary.laggingCount, now, signalSince);
const alertsCreated: string[] = [];
if ((signal === 'moderate' || signal === 'strong') && prev?.signal !== signal) {
const { createAlert } = await import('../alerts/AlertEngine.ts');
const userIds = ctx.db.prepare('SELECT id FROM users').all() as Array<{ id: string }>;
for (const u of userIds) {
const deductionKey = `rotation_incipient:${signal}:${summary.leadingGroup}`;
const existing = ctx.db.prepare('SELECT id FROM alert_events WHERE dedup_key=? AND user_id=?').get(deductionKey, u.id);
if (existing) continue;
const alert = createAlert(
crypto.randomUUID(), u.id, 'rotation_incipient' as any, undefined,
`Relative leadership may be shifting (${signal}). Leading: ${summary.leadingGroup}. Lagging: ${summary.laggingGroup}. Spread ~${spread.toFixed(1)} pp vs SPY. Educational notice — not an allocation instruction.`,
signal,
{ spread, signal, leadingGroup: summary.leadingGroup, laggingGroup: summary.laggingGroup },
);
ctx.db.prepare(
`INSERT INTO alert_events (id, user_id, type, severity, title, description, created_at, acknowledged, dedup_key, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
).run(alert.id, u.id, alert.type, alert.severity, alert.title, alert.description, alert.createdAt, alert.dedupKey, JSON.stringify(alert.payload));
alertsCreated.push(u.id);
}
}
return {
signal,
spread,
inflowAvg: summary.leadingCount,
outflowAvg: summary.laggingCount,
prevSignal: prev?.signal ?? null,
alertsCreated: alertsCreated.length,
leadingGroup: summary.leadingGroup,
laggingGroup: summary.laggingGroup,
};
}),
/** Relative-strength rotation map (vs SPY). Not absolute-return "flow". */
rotation: publicProcedure.query(async ({ ctx }) => {
const {
MARKET_ROTATION_UNIVERSE,
BENCHMARK_SYMBOL,
buildSectorRsMap,
summarizeRotation,
} = await import('../analysis/marketRotationRs.ts');
const symbols = [BENCHMARK_SYMBOL, ...MARKET_ROTATION_UNIVERSE.map((s) => s.symbol)];
for (const sym of symbols) {
try { await ctx.cache.subscribe(sym, 'etf'); } catch { /* ignore */ }
}
const keys = symbols.map((s) => `yfinance:candles:${s}:1d`);
const entries = await ctx.cache.getMany<PriceCandle[]>(keys);
const map: Record<string, PriceCandle[]> = {};
let dataPoints = 0;
symbols.forEach((s, i) => {
const candles = (entries[i]?.value ?? []) as PriceCandle[];
map[s] = candles;
if (candles.length > 0) dataPoints += 1;
});
const rows = buildSectorRsMap(
MARKET_ROTATION_UNIVERSE,
map,
map[BENCHMARK_SYMBOL] ?? [],
);
const summary = summarizeRotation(rows);
const state = ctx.db.prepare('SELECT signal_since FROM rotation_state WHERE id=?').get('singleton') as { signal_since: string | null } | undefined;
const signalSince = state?.signal_since ?? null;
const daysSince = signalSince ? Math.round((Date.now() - new Date(signalSince).getTime()) / 86_400_000) : null;
// Backward-compatible sector cards (group = leadership, not abs-return inflow).
const sectors = rows.map((r) => ({
symbol: r.symbol,
name: r.name,
group: r.leadership === 'leading' ? 'inflow' as const
: r.leadership === 'lagging' ? 'outflow' as const
: 'inflow' as const, // inline treated as neutral; UI will use leadership field
subGroup: r.group,
kind: r.kind,
leadership: r.leadership,
earlyWatch: r.earlyWatch,
rank1M: r.rank1M,
rank1W: r.rank1W,
relVol: r.relVol,
oneWeek: r.abs.oneWeek,
oneMonth: r.abs.oneMonth,
threeMonth: r.abs.threeMonth,
sixMonth: r.abs.sixMonth,
oneYear: r.abs.oneYear,
rsOneWeek: r.rs.oneWeek,
rsOneMonth: r.rs.oneMonth,
rsThreeMonth: r.rs.threeMonth,
rsSixMonth: r.rs.sixMonth,
rsOneYear: r.rs.oneYear,
}));
const signal = {
strength: summary.strength,
spread: summary.leadershipSpread,
// Legacy field names kept for RotationSignalAlert; mean stronger/weaker counts.
inflowAvg: summary.leadingCount,
outflowAvg: summary.laggingCount,
leadingSubGroup: summary.leadingGroup,
laggingSubGroup: summary.laggingGroup,
inflowConfirmation: `${summary.leadingCount}/${rows.length}`,
outflowConfirmation: `${summary.laggingCount}/${rows.length}`,
earlyWatchCount: summary.earlyWatchCount,
daysSince,
summary: summary.summary,
};
// Persist today's rank snapshot so we can show history later.
try {
const asOf = new Date().toISOString().slice(0, 10);
const ins = ctx.db.prepare(
`INSERT OR REPLACE INTO rotation_rank_snapshots
(as_of_date, symbol, name, grp, rank_1m, rs_1m, rank_1w, rs_1w, leadership, early_watch, strength)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
);
for (const r of rows) {
ins.run(
asOf, r.symbol, r.name, r.group,
r.rank1M, r.rs.oneMonth, r.rank1W, r.rs.oneWeek,
r.leadership, r.earlyWatch ? 1 : 0, summary.strength,
);
}
} catch { /* table may not exist until migration; ignore */ }
// Recent early-watch history (last 14 days).
let earlyHistory: Array<{ date: string; symbol: string; name: string; rs1w: number | null }> = [];
try {
earlyHistory = ctx.db.prepare(
`SELECT as_of_date AS date, symbol, name, rs_1w AS rs1w
FROM rotation_rank_snapshots
WHERE early_watch = 1 AND as_of_date >= date('now', '-14 day')
ORDER BY as_of_date DESC, rank_1w ASC
LIMIT 40`,
).all() as Array<{ date: string; symbol: string; name: string; rs1w: number | null }>;
} catch { /* ignore */ }
return {
sectors,
signal,
benchmark: BENCHMARK_SYMBOL,
coveredSymbols: dataPoints,
totalSymbols: symbols.length,
earlyHistory,
fetchedAt: new Date().toISOString(),
lesson: 'Relative strength compares each ETF to the broad market (SPY). Outperformance means higher return than SPY over the window — not measured ETF share creation or redemption flows.',
};
}),
/** Historical seasonality for SPY (and optional symbol) + simple calendar. */
seasonality: publicProcedure
.input(z.object({ symbol: z.string().min(1).max(12).optional() }).optional())
.query(async ({ ctx, input }) => {
const { buildSeasonalitySnapshot, upcomingSimpleEvents } = await import('../analysis/seasonality.ts');
const symbol = (input?.symbol ?? 'SPY').toUpperCase();
try { await ctx.cache.subscribe(symbol, symbol === 'SPY' ? 'etf' : 'equity'); } catch { /* ignore */ }
const entry = await ctx.cache.get<PriceCandle[]>(`yfinance:candles:${symbol}:1d`);
const candles = (entry?.value ?? []) as PriceCandle[];
const snapshot = buildSeasonalitySnapshot(
symbol,
candles.map((c) => ({ ts: c.ts, c: c.c })),
);
return {
...snapshot,
events: upcomingSimpleEvents(),
hasEnoughHistory: candles.length >= 250,
lesson: 'Historical monthly averages are context only. They do not forecast the current year.',
};
}),
/**
* Top holdings for a sector/theme ETF (e.g. SMH, XLK).
* Resolution order: fresh kv_cache → live Yahoo → stale cache → static fallback.
* Day quotes from local `quotes` only (no Yahoo chart/quote fanout under rate limits).
*/
sectorHoldings: publicProcedure
.input(z.object({ etfSymbol: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const { staticHoldingsFor } = await import('../analysis/etfHoldingsFallback.ts');
const symbol = input.etfSymbol.toUpperCase();
const cacheKey = `etf:topHoldings:${symbol}`;
const FRESH_MS = 24 * 60 * 60_000;
type HoldingRow = { symbol: string; holdingName: string; holdingPercent: number };
function readCachedComposition(): { rows: HoldingRow[]; observedAt: string } | null {
try {
const row = ctx.db.prepare('SELECT value, observed_at FROM kv_cache WHERE key=?').get(cacheKey) as
| { value: string; observed_at: string } | undefined;
if (!row?.value) return null;
const parsed = JSON.parse(row.value) as { holdings?: HoldingRow[] };
const rows = (parsed.holdings ?? [])
.filter((h) => h?.symbol)
.map((h) => ({
symbol: String(h.symbol).toUpperCase(),
holdingName: h.holdingName ?? String(h.symbol),
holdingPercent: Number(h.holdingPercent) || 0,
}));
if (!rows.length) return null;
return { rows, observedAt: row.observed_at };
} catch {
return null;
}
}
function writeCachedComposition(rows: HoldingRow[]): void {
try {
ctx.db.prepare(
'INSERT OR REPLACE INTO kv_cache (key, value, observed_at) VALUES (?,?,?)',
).run(cacheKey, JSON.stringify({ symbol, holdings: rows }), new Date().toISOString());
} catch { /* best-effort; SQLite lock under concurrent write is fine */ }
}
function fromStatic(): HoldingRow[] {
return staticHoldingsFor(symbol).map((h) => ({
symbol: h.symbol.toUpperCase(),
holdingName: h.holdingName,
holdingPercent: h.holdingPercent,
}));
}
let holdings: HoldingRow[] = [];
let source: 'live' | 'cache' | 'static' | 'empty' = 'empty';
const cached = readCachedComposition();
const cacheAgeMs = cached ? Date.now() - Date.parse(cached.observedAt) : Infinity;
const cacheFresh = Number.isFinite(cacheAgeMs) && cacheAgeMs < FRESH_MS;
if (cacheFresh && cached) {
holdings = cached.rows;
source = 'cache';
} else if (cached) {
// Stale composition is fine for a peek panel; do not block on Yahoo under rate limits.
holdings = cached.rows;
source = 'cache';
} else {
// Cold path: static first (instant), then optional short Yahoo upgrade.
const fallback = fromStatic();
if (fallback.length > 0) {
holdings = fallback;
source = 'static';
writeCachedComposition(fallback);
}
try {
const mod = await import('yahoo-finance2');
const yf = new mod.default();
const summary = await Promise.race([
yf.quoteSummary(symbol, { modules: ['topHoldings'] }) as Promise<{
topHoldings?: { holdings?: HoldingRow[] };
}>,
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('yahoo-topHoldings-timeout')), 2500);
}),
]);
const live = (summary?.topHoldings?.holdings ?? [])
.filter((h) => h?.symbol)
.map((h) => ({
symbol: String(h.symbol).toUpperCase(),
holdingName: h.holdingName ?? String(h.symbol),
holdingPercent: Number(h.holdingPercent) || 0,
}));
if (live.length > 0) {
holdings = live;
source = 'live';
writeCachedComposition(live);
}
} catch {
/* rate limit / timeout / network — keep static or empty */
}
}
if (holdings.length === 0) {
return { symbol, holdings: [], source: 'empty' as const };
}
const symbols = holdings.map((h) => h.symbol.toUpperCase());
// Local quotes only — never open another Yahoo batch from this endpoint.
const quoteMap = new Map<string, {
price: number | null;
change: number | null;
changePercent: number | null;
volume: number | null;
marketCap: number | null;
}>();
try {
const placeholders = symbols.map(() => '?').join(',');
const rows = ctx.db.prepare(
`SELECT symbol, price, change, change_pct FROM quotes WHERE symbol IN (${placeholders})`,
).all(...symbols) as Array<{
symbol: string;
price: number | null;
change: number | null;
change_pct: number | null;
}>;
for (const r of rows) {
quoteMap.set(r.symbol.toUpperCase(), {
price: r.price,
change: r.change,
changePercent: r.change_pct,
volume: null,
marketCap: null,
});
}
} catch { /* local quotes optional */ }
const result = holdings.map((h) => {
const sym = h.symbol.toUpperCase();
const q = quoteMap.get(sym);
return {
symbol: sym,
name: h.holdingName,
weight: h.holdingPercent,
price: q?.price ?? null,
change: q?.change ?? null,
changePercent: q?.changePercent ?? null,
volume: q?.volume ?? null,
marketCap: q?.marketCap ?? null,
returns1W: null as number | null,
returns1M: null as number | null,
returns3M: null as number | null,
returns6M: null as number | null,
returns1Y: null as number | null,
};
});
return { symbol, holdings: result, source };
}),
});
function parseCandlesFromChart(raw: Record<string, unknown>): PriceCandle[] {
const quotes = raw.quotes;
if (!Array.isArray(quotes)) return [];
const out: PriceCandle[] = [];
for (const q of quotes as Array<Record<string, unknown> | null>) {
if (!q) continue;
const close = typeof q.adjclose === 'number' && Number.isFinite(q.adjclose) ? q.adjclose
: (typeof q.close === 'number' && Number.isFinite(q.close) ? q.close : null);
if (close === null) continue;
out.push({
ts: q.date instanceof Date ? q.date.toISOString() : (typeof q.date === 'string' ? q.date : ''),
o: typeof q.open === 'number' ? q.open : 0,
h: typeof q.high === 'number' ? q.high : 0,
l: typeof q.low === 'number' ? q.low : 0,
c: close,
v: typeof q.volume === 'number' ? q.volume : 0,
});
}
return out;
}
// ---------------------------------------------------------------------------
// admin.* (Slice 25) — operator tooling, auth-gated to is_admin=1.
// ---------------------------------------------------------------------------
const adminRouter = router({
usersList: adminProcedure.query(({ ctx }) => listUsers(ctx.db)),
setUserModules: adminProcedure
.input(z.object({ userId: z.string().uuid(), modules: z.array(z.string()) }))
.mutation(({ ctx, input }) => {
return setUserModules(ctx.db, ctx.userId, input.userId, input.modules);
}),
disableUser: adminProcedure
.input(z.object({ userId: z.string().uuid() }))
.mutation(({ ctx, input }) => {
try { return disableUser(ctx.db, ctx.userId, input.userId); }
catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to disable user.' }); }
}),
enableUser: adminProcedure
.input(z.object({ userId: z.string().uuid() }))
.mutation(({ ctx, input }) => {
try { return enableUser(ctx.db, ctx.userId, input.userId); }
catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to enable user.' }); }
}),
deleteUser: adminProcedure
.input(z.object({ userId: z.string().uuid() }))
.mutation(({ ctx, input }) => {
try { return deleteUser(ctx.db, ctx.userId, input.userId); }
catch (e) { throw new TRPCError({ code: 'FORBIDDEN', message: e instanceof Error ? e.message : 'Failed to delete user.' }); }
}),
resetPassword: adminProcedure
.input(z.object({ email: z.string().email(), tempPassword: z.string().min(8) }))
.mutation(({ ctx, input }) => {
try {
return resetPassword(ctx.db, ctx.userId, input.email, hashPassword(input.tempPassword));
} catch (e) {
if (e instanceof NotOwnerError) throw new TRPCError({ code: 'FORBIDDEN', message: e.message });
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
}),
gdprExport: adminProcedure
.input(z.object({ userId: z.string().uuid() }))
.mutation(({ ctx, input }) => {
try {
return gdprExport(ctx.db, ctx.userId, input.userId);
} catch (e) {
if (e instanceof NotOwnerError) throw new TRPCError({ code: 'FORBIDDEN', message: e.message });
throw e;
}
}),
queueHealth: adminProcedure.query(({ ctx }) => queueHealth(ctx.db)),
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 };
}),
queueLint: adminProcedure
.input(z.object({ symbol: z.string().min(1).max(10), kind: z.enum(['sec-lint-holders', 'sec-lint-insiders']) }))
.mutation(async ({ ctx, input }) => {
const { lintInstitutionalHolders, lintInsiderTransactions, lintSCFilings } = await import('../services/secDataFetcher.ts');
let result: LintResult;
if (input.kind === 'sec-lint-holders') {
result = await lintInstitutionalHolders(ctx.db, input.symbol);
await lintSCFilings(ctx.db, input.symbol).catch(() => {});
} else {
result = await lintInsiderTransactions(ctx.db, input.symbol);
}
// Also enqueue so the weekly schedule picks it up too.
try { ctx.queue.queue(`${input.kind}:${input.kind === 'sec-lint-holders' ? 'holders' : 'insiders'}:${input.symbol}`); } catch { /* non-fatal */ }
return result;
}),
dataQualityList: adminProcedure
.input(z.object({ symbol: z.string().min(1).max(20).optional(), kind: z.enum(['institution_filings', 'insider_transactions']).nullish() }))
.query(({ ctx, input }) => {
let sql = 'SELECT symbol, kind, last_checked_at, stored_count, discovered_count, missing_count, stale, status, detail FROM data_quality WHERE 1=1';
const params: SQLInputValue[] = [];
if (input.symbol) { sql += ' AND symbol = ?'; params.push(input.symbol); }
if (input.kind) { sql += ' AND kind = ?'; params.push(input.kind); }
sql += ' ORDER BY last_checked_at DESC LIMIT 200';
return ctx.db.prepare(sql).all(...params) as Array<Record<string, unknown>>;
}),
queueLintAll: adminProcedure
.input(z.object({ kind: z.enum(['sec-lint-holders', 'sec-lint-insiders']) }))
.mutation(async ({ ctx, input }) => {
const { lintInstitutionalHolders, lintInsiderTransactions } = await import('../services/secDataFetcher.ts');
const symbols = (ctx.db.prepare('SELECT symbol FROM symbol_demand WHERE in_demand=1 ORDER BY symbol').all() as Array<{ symbol: string }>).map((r) => r.symbol);
const results: LintResult[] = [];
for (const sym of symbols) {
try {
if (input.kind === 'sec-lint-holders') {
results.push(await lintInstitutionalHolders(ctx.db, sym));
} else {
results.push(await lintInsiderTransactions(ctx.db, sym));
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
results.push({ symbol: sym.toUpperCase(), kind: input.kind === 'sec-lint-holders' ? 'institution_filings' : 'insider_transactions', discoveredCount: 0, storedCount: 0, missingCount: 0, backfilled: 0, stale: 1, status: 'error', detail: { reason: msg } });
}
}
return { total: symbols.length, results };
}),
// ---------------------------------------------------------------------------
// Admin: X cookie credentials & tracked accounts management.
// Credentials are stored AES-256-GCM encrypted at rest; never returned to clients.
// ---------------------------------------------------------------------------
xCredentialsStatus: adminProcedure.query(({ ctx }) => {
const row = ctx.db.prepare('SELECT healthy, last_error, updated_at FROM x_credentials WHERE id=?').get('singleton') as { healthy?: number; last_error?: string | null; updated_at?: string } | undefined;
if (!row) return { configured: false, healthy: 'degraded' as const, lastError: null, updatedAt: null };
return {
configured: true,
healthy: row.healthy === 1 ? ('healthy' as const) : ('degraded' as const),
lastError: row.last_error ?? null,
updatedAt: row.updated_at ?? null,
};
}),
xCredentialsSet: adminProcedure
.input(z.object({ ct0: z.string().min(1), auth_token: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const encrypt = (await import('../lib/crypto.ts')).default;
try {
const now = new Date().toISOString();
const ct0_enc = encrypt(input.ct0);
const auth_token_enc = encrypt(input.auth_token);
ctx.db.prepare(
`INSERT INTO x_credentials (id, ct0_enc, auth_token_enc, healthy, last_error, updated_at)
VALUES ('singleton', ?, ?, 1, NULL, ?)
ON CONFLICT(id) DO UPDATE SET ct0_enc=excluded.ct0_enc, auth_token_enc=excluded.auth_token_enc, healthy=1, last_error=NULL, updated_at=excluded.updated_at`
).run(ct0_enc, auth_token_enc, now);
ctx.xAdapter?.setCookies({ ct0: input.ct0, auth_token: input.auth_token });
ctx.cache.del('x:cashtag:*');
return { ok: true };
} catch (e) {
throw new TRPCError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Failed to set X credentials.' });
}
}),
// ---------------------------------------------------------------------------
// Admin: FRED API key management.
// ---------------------------------------------------------------------------
fredKeyStatus: adminProcedure.query(({ ctx }) => {
const row = ctx.db.prepare('SELECT fred_api_key_enc FROM x_credentials WHERE id=?').get('singleton') as { fred_api_key_enc?: string | null } | undefined;
return { configured: !!(row?.fred_api_key_enc) };
}),
fredKeySet: adminProcedure
.input(z.object({ apiKey: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const encrypt = (await import('../lib/crypto.ts')).default;
try {
const now = new Date().toISOString();
const fred_api_key_enc = encrypt(input.apiKey);
ctx.db.prepare(
`INSERT INTO x_credentials (id, fred_api_key_enc, updated_at)
VALUES ('singleton', ?, ?)
ON CONFLICT(id) DO UPDATE SET fred_api_key_enc=excluded.fred_api_key_enc, updated_at=excluded.updated_at`
).run(fred_api_key_enc, now);
return { ok: true };
} catch (e) {
throw new TRPCError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Failed to set FRED API key.' });
}
}),
xAccountsList: adminProcedure.query(({ ctx }) => {
const rows = ctx.db.prepare("SELECT id, symbol, handle, COALESCE(label, '') AS label, created_at FROM x_accounts ORDER BY symbol, handle").all();
return (rows ?? []) as Array<{id: string; symbol: string; handle: string; label: string; created_at: string}>;
}),
xAccountAdd: adminProcedure
.input(z.object({ symbol: z.string().min(1).max(20), handle: z.string().min(1).regex(/^[a-zA-Z0-9_]+$/), label: z.string().optional() }))
.mutation(async ({ ctx, input }) => {
try {
const id = randomUUID();
const now = new Date().toISOString();
ctx.db.prepare('INSERT INTO x_accounts (id, symbol, handle, label, created_at) VALUES (?, ?, ?, ?, ?)').run(id, input.symbol.toUpperCase(), input.handle.toLowerCase(), input.label ?? null, now);
// Fire-and-forget 30-day backfill.
setTimeout(() => {
import('../x/backfill.ts').then(({ backfillAccount }) =>
backfillAccount(ctx.db, input.symbol.toUpperCase(), input.handle.toLowerCase())
).catch((e) => console.error('[xAccountAdd] backfill failed:', e));
}, 0);
return { id };
} catch (e) {
if ((e as Error).message?.includes('UNIQUE constraint')) throw new TRPCError({ code: 'CONFLICT', message: `Account @${input.handle} already tracked for $${input.symbol}` });
throw new TRPCError({ code: 'BAD_REQUEST', message: e instanceof Error ? e.message : 'Failed to add account.' });
}
}),
xAccountRemove: adminProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(({ ctx, input }) => {
const info = ctx.db.prepare('DELETE FROM x_accounts WHERE id=?').run(input.id);
if (info.changes === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Account not found.' });
return { ok: true };
}),
xPrune: adminProcedure
.input(z.object({
olderThanDays: z.number().int().min(1).max(365).optional().default(30),
}))
.mutation(({ ctx, input }) => {
const cutoff = new Date(Date.now() - input.olderThanDays * 24 * 60 * 60 * 1000).toISOString();
const info = ctx.db.prepare('DELETE FROM x_cookie_posts WHERE posted_at < ?').run(cutoff);
return { deleted: info.changes };
}),
pendingUsers: adminProcedure.query(({ ctx }) => {
return ctx.db.prepare('SELECT id, email, complexity, created_at FROM users WHERE status=? ORDER BY created_at ASC').all('pending_approval') as Array<{ id: string; email: string; complexity: string; created_at: string }>;
}),
approveUser: adminProcedure
.input(z.object({ userId: z.string().uuid() }))
.mutation(({ ctx, input }) => {
const info = ctx.db.prepare('UPDATE users SET status=? WHERE id=? AND status=?').run('active', input.userId, 'pending_approval');
if (info.changes === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'No pending user found with that ID.' });
return { ok: true };
}),
rejectUser: adminProcedure
.input(z.object({ userId: z.string().uuid() }))
.mutation(({ ctx, input }) => {
const info = ctx.db.prepare('UPDATE users SET status=? WHERE id=? AND status=?').run('rejected', input.userId, 'pending_approval');
if (info.changes === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'No pending user found with that ID.' });
return { ok: true };
}),
// Restart the app servers from the admin console. Responds immediately, then
// restarts the backend (and best-effort the Next frontend) after a short beat.
serverRestart: adminProcedure
.input(z.object({ target: z.enum(['backend', 'frontend', 'all']).optional().default('all') }))
.mutation(({ input }) => {
const status = restartServers((input.target ?? 'all') as RestartTarget);
return { ok: true, ...status };
}),
/** Get SMTP config (admin-only). */
smtpConfig: adminProcedure.query(async ({ ctx }) => {
const { readSmtpConfig } = await import('../services/emailAlertService.ts');
return readSmtpConfig(ctx.db) ?? { enabled: false };
}),
/** Update SMTP config (admin-only). */
smtpConfigUpdate: adminProcedure
.input(z.object({
host: z.string().optional(),
port: z.number().int().optional(),
secure: z.boolean().optional(),
user: z.string().optional().nullable(),
pass: z.string().optional().nullable(),
fromName: z.string().optional(),
fromEmail: z.string().optional(),
enabled: z.boolean().optional(),
}))
.mutation(({ ctx, input }) => {
const config = ctx.db.prepare('SELECT * FROM smtp_config WHERE id = ?').get('singleton') as Record<string, unknown> | undefined;
const now = new Date().toISOString();
if (!config) {
ctx.db.prepare(
`INSERT INTO smtp_config (id, host, port, secure, user, pass_enc, from_name, from_email, enabled, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run('singleton', input.host ?? 'smtp.mail.me.com', input.port ?? 587, input.secure ? 1 : 0, input.user ?? null, input.pass ?? null, input.fromName ?? 'Investor Flow', input.fromEmail ?? '', input.enabled ? 1 : 0, now);
} else {
const updates: string[] = [];
const params: (string | number | null)[] = [];
if (input.host !== undefined) { updates.push('host = ?'); params.push(input.host); }
if (input.port !== undefined) { updates.push('port = ?'); params.push(input.port); }
if (input.secure !== undefined) { updates.push('secure = ?'); params.push(input.secure ? 1 : 0); }
if (input.user !== undefined) { updates.push('user = ?'); params.push(input.user); }
if (input.pass !== undefined) { updates.push('pass_enc = ?'); params.push(input.pass); }
if (input.fromName !== undefined) { updates.push('from_name = ?'); params.push(input.fromName); }
if (input.fromEmail !== undefined) { updates.push('from_email = ?'); params.push(input.fromEmail); }
if (input.enabled !== undefined) { updates.push('enabled = ?'); params.push(input.enabled ? 1 : 0); }
updates.push('updated_at = ?');
params.push(now);
params.push('singleton');
ctx.db.prepare(`UPDATE smtp_config SET ${updates.join(', ')} WHERE id = ?`).run(...params);
}
return { ok: true };
}),
/** Test SMTP config by sending a test email to the admin. */
smtpConfigTest: adminProcedure.mutation(async ({ ctx }) => {
const { readSmtpConfig, sendAlertEmail } = await import('../services/emailAlertService.ts');
const config = readSmtpConfig(ctx.db);
if (!config || !config.enabled) throw new TRPCError({ code: 'BAD_REQUEST', message: 'SMTP not configured or disabled.' });
const testAlert = {
id: 'test',
userId: ctx.userId as string,
type: 'rotation_incipient' as const,
severity: 'info' as const,
title: 'Test alert from Investor Flow',
description: 'This is a test email to verify SMTP configuration.',
symbol: undefined,
createdAt: new Date().toISOString(),
acknowledged: false,
dedupKey: 'test:' + Date.now(),
payload: {},
};
const sent = await sendAlertEmail(ctx.db, testAlert);
if (!sent) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to send test email.' });
return { ok: true };
}),
});
// 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 };
}),
/** Create an alert subscription rule. */
createSubscription: protectedProcedure
.input(z.object({
alertType: z.string(),
watchlistId: z.string().optional(),
symbol: z.string().optional(),
params: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const { createAlertSubscription } = await import('../db/alertSubscriptionRepository.ts');
const sub = createAlertSubscription(ctx.db, userId, {
alertType: input.alertType,
watchlistId: input.watchlistId,
symbol: input.symbol,
params: input.params,
});
return sub;
}),
/** List alert subscriptions (optionally filtered by symbol). */
listSubscriptions: protectedProcedure
.input(z.object({ symbol: z.string().optional() }).optional())
.query(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const { listAlertSubscriptions } = await import('../db/alertSubscriptionRepository.ts');
return listAlertSubscriptions(ctx.db, userId, input?.symbol);
}),
/** Update an alert subscription (enable/disable, change params). */
updateSubscription: protectedProcedure
.input(z.object({
id: z.string(),
enabled: z.boolean().optional(),
params: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const { updateAlertSubscription } = await import('../db/alertSubscriptionRepository.ts');
const sub = updateAlertSubscription(ctx.db, userId, input.id, {
enabled: input.enabled,
params: input.params,
});
if (!sub) throw new TRPCError({ code: 'NOT_FOUND', message: 'Subscription not found.' });
return sub;
}),
/** Delete an alert subscription. */
deleteSubscription: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const { deleteAlertSubscription } = await import('../db/alertSubscriptionRepository.ts');
const deleted = deleteAlertSubscription(ctx.db, userId, input.id);
if (!deleted) throw new TRPCError({ code: 'NOT_FOUND', message: 'Subscription not found.' });
return { ok: true };
}),
});
// ─── Institutional Flow Router (Slice 7 / M4 + M5) ────────────────────────
interface InstitutionalFlowResult {
cusip: string;
name: string;
prevShares: number;
currShares: number;
delta: number;
classification: string;
holderClass?: string;
}
interface InsiderStreamEvent {
reporter: string;
relationship: string;
securityTitle: string;
transactionDate: string;
transactionCode: string;
shares: number;
price: number;
netDirection: string;
is10b5Plan?: boolean;
planDetails?: string;
}
interface InsiderStreamSummary {
cik: string;
events: InsiderStreamEvent[];
netShares: number;
direction: string | null;
transactionType: 'Informed' | 'Routine';
}
const institutionalRouter = router({
/** Get institutional flow for a symbol (M4 per-symbol view). */
flow: publicProcedure
.input(z.object({ symbol: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
// Query institution_filings for this symbol, grouped by filer_cik
const rows = ctx.db.prepare(`
SELECT filer_cik, filer_name, symbol, form, shares, value_usd,
reported_quarter, filed_at, put_call
FROM institution_filings
WHERE symbol = ?
ORDER BY filed_at DESC
`).all(symbol) as Array<{
filer_cik: string;
filer_name: string | null;
symbol: string;
form: string;
shares: number | null;
value_usd: number | null;
reported_quarter: string;
filed_at: string;
put_call: string | null;
}>;
if (rows.length === 0) {
return { symbol, flow: [], quarters: [], filings: [] };
}
// Group by filer_cik, then by reported_quarter to compute QoQ flow
const byCik = new Map<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,
putCall: r.put_call ?? '',
}));
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,
};
}),
/** Aggregate institutional ownership by month for overlay charts. */
ownershipHistory: publicProcedure
.input(z.object({ symbol: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const rows = ctx.db.prepare(`
SELECT substr(filed_at, 1, 7) as month, SUM(shares) as total_shares, COUNT(DISTINCT filer_cik) as num_filers
FROM institution_filings
WHERE symbol = ? AND shares IS NOT NULL
GROUP BY month
ORDER BY month ASC
`).all(symbol) as Array<{ month: string; total_shares: number; num_filers: number }>;
return { symbol, months: rows };
}),
/** Per-filer buy events computed from QoQ 13F delta. */
buyEvents: publicProcedure
.input(z.object({ symbol: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const rows = ctx.db.prepare(`
SELECT filer_cik, filer_name, reported_quarter, shares, value_usd
FROM institution_filings
WHERE symbol = ? AND shares IS NOT NULL AND value_usd IS NOT NULL
ORDER BY filer_cik, reported_quarter ASC
`).all(symbol) as Array<{
filer_cik: string; filer_name: string | null;
reported_quarter: string; shares: number; value_usd: number;
}>;
const byFiler = new Map<string, typeof rows>();
for (const r of rows) {
const arr = byFiler.get(r.filer_cik) ?? [];
arr.push(r);
byFiler.set(r.filer_cik, arr);
}
const events: Array<{
filerName: string | null; filerCik: string;
sharesAdded: number; estimatedPrice: number; date: string;
}> = [];
for (const [, filerRows] of byFiler) {
for (let i = 1; i < filerRows.length; i++) {
const prev = filerRows[i - 1];
const curr = filerRows[i];
const delta = curr.shares - prev.shares;
if (delta > 0) {
const price = curr.value_usd > 0 ? curr.value_usd / curr.shares : 0;
const parts = curr.reported_quarter.split('-Q');
const y = parseInt(parts[0]);
const q = parseInt(parts[1]);
const month = q * 3; // Q1=3, Q2=6, Q3=9, Q4=12
const lastDay = new Date(y, month, 0).getDate();
const date = `${y}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
events.push({ filerName: curr.filer_name, filerCik: curr.filer_cik, sharesAdded: delta, estimatedPrice: price, date });
}
}
}
return { symbol, events };
}),
/** Analyst ratings (upgrades/downgrades + consensus) from yahoo-finance2. */
analystRatings: publicProcedure
.input(z.object({ symbol: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const { fetchAndStoreAnalystRatings, getAnalystRatings } = await import('../services/analystRatingsService.ts');
// Try cache first
const cached = getAnalystRatings(ctx.db, symbol);
if (cached && !cached.stale) return { symbol, ratings: cached.ratings, consensus: cached.consensus };
// Fetch fresh
const result = await fetchAndStoreAnalystRatings(ctx.db, symbol);
if ('error' in result) {
// If we have cached data, serve it stale rather than erroring
if (cached) return { symbol, ratings: cached.ratings, consensus: cached.consensus, stale: true };
return { symbol, ratings: [], consensus: null, error: result.error };
}
return { symbol, ratings: result.ratings, consensus: result.consensus };
}),
/** Short interest (shares short, % float, short ratio) from yahoo-finance2 defaultKeyStatistics. */
shortInterest: publicProcedure
.input(z.object({ symbol: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const k = `yfinance:shortinterest:${symbol}`;
const entry = await ctx.cache.get<unknown>(k);
const value = entry.value as Record<string, unknown> | null;
return {
symbol,
sharesShort: value ? (value.sharesShort as number | null) : null,
sharesShortPriorMonth: value ? (value.sharesShortPriorMonth as number | null) : null,
sharesPercentSharesOut: value ? (value.sharesPercentSharesOut as number | null) : null,
shortRatio: value ? (value.shortRatio as number | null) : null,
shortPercentOfFloat: value ? (value.shortPercentOfFloat as number | null) : null,
dateShortInterest: value ? (value.dateShortInterest as string | null) : null,
floatShares: value ? (value.floatShares as number | null) : null,
sharesOutstanding: value ? (value.sharesOutstanding as number | null) : null,
stale: entry.isStale,
provenance: entry.provenance,
};
}),
};
// ─── EDGAR / SEC Filings Router (Slice 6 / M6) ────────────────────────────
interface EdgarFiling {
form: string;
dateReporter: string;
accessionNumber: string;
accessionNormalization: string;
reportDate: string;
reportFile: string;
primaryDocument: string;
}
interface FilerMeta {
cik: string;
name: string | null;
sic: string | null;
}
interface Form13fHoldingsResult {
holdings: Array<{
cusip: string;
issuerName: string;
value: number;
sshPrnamt: number;
}>;
total: number;
accession: string;
}
interface Form4TxResult {
transactions: Array<{
reporter: string;
relationship: string;
securityTitle: string;
transactionDate: string;
transactionCode: string;
shares: number;
price: number;
}>;
accession: string;
}
const edgarRouter = router({
/** Recent filings for a CIK, filterable by form type + date range. */
filings_index: publicProcedure
.input(z.object({
cik: z.string().min(1),
formTypes: z.array(z.string()).optional(),
dateRange: z.object({ from: z.string().optional(), to: z.string().optional() }).optional(),
}))
.query(async ({ ctx, input }) => {
const edgar = new EdgarAdapter();
try {
const result = await edgar.filings_index(input.cik, {
formTypes: input.formTypes,
dateRange: input.dateRange,
});
return { filings: result.value as EdgarFiling[], cik: input.cik };
} catch (e) {
throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message });
}
}),
/** Company facts (XBRL-derived financials) for a CIK. */
company_facts: publicProcedure
.input(z.object({ cik: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const edgar = new EdgarAdapter();
try {
const result = await edgar.company_facts(input.cik);
return { facts: result.value as Record<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 (server-side paginated). */
form13f_holdings: publicProcedure
.input(z.object({
cik: z.string().min(1),
accession: z.string().min(1),
limit: z.number().int().positive().optional(),
offset: z.number().int().min(0).optional(),
}))
.query(async ({ ctx, input }) => {
const edgar = new EdgarAdapter();
try {
const result = await edgar.form13f_holdings(input.cik, input.accession, {
limit: input.limit,
offset: input.offset,
});
const v = result.value as Form13fHoldingsResult;
return { holdings: v.holdings, total: v.total, accession: v.accession };
} catch (e) {
throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message });
}
}),
/** Form 4 insider transactions for a CIK + accession. */
form4_tx: publicProcedure
.input(z.object({ cik: z.string().min(1), accession: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const edgar = new EdgarAdapter();
try {
const result = await edgar.form4_tx(input.cik, input.accession);
return { transactions: result.value as Form4TxResult };
} catch (e) {
throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message });
}
}),
});
// ─── Watchlist Router (Slice 10) ─────────────────────────────────────────────
const watchlistRouter = router({
/** List all symbols across all watchlists for the user. */
list: publicProcedure
.query(async ({ ctx }) => {
const userId = ctx.userId ?? 'anonymous';
const { listSymbols } = await import('../db/watchlistRepository.ts');
return listSymbols(ctx.db, userId);
}),
/** List symbols for a specific watchlist. */
listByWatchlist: publicProcedure
.input(z.object({ name: z.string().min(1).max(100) }))
.query(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { listSymbolsByWatchlist } = await import('../db/watchlistRepository.ts');
return listSymbolsByWatchlist(ctx.db, userId, input.name);
}),
/** List watchlist metadata (name, symbol count, sort order). */
listWatchlists: publicProcedure
.query(async ({ ctx }) => {
const userId = ctx.userId ?? 'anonymous';
const { listWatchlists } = await import('../db/watchlistRepository.ts');
return listWatchlists(ctx.db, userId);
}),
/** Create a new named watchlist. */
create: publicProcedure
.input(z.object({ name: z.string().min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { createWatchlist } = await import('../db/watchlistRepository.ts');
return createWatchlist(ctx.db, userId, input.name);
}),
/** Delete a watchlist by name. */
delete: publicProcedure
.input(z.object({ name: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { deleteWatchlist } = await import('../db/watchlistRepository.ts');
return { deleted: deleteWatchlist(ctx.db, userId, input.name) };
}),
/** Rename a watchlist. */
rename: publicProcedure
.input(z.object({ oldName: z.string().min(1), newName: z.string().min(1).max(100) }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { renameWatchlist } = await import('../db/watchlistRepository.ts');
return { renamed: renameWatchlist(ctx.db, userId, input.oldName, input.newName) };
}),
/** Reorder watchlists (bulk update sort_order). */
reorder: publicProcedure
.input(z.object({ orders: z.array(z.object({ id: z.string(), sort_order: z.number().int() })) }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { reorderWatchlists } = await import('../db/watchlistRepository.ts');
reorderWatchlists(ctx.db, userId, input.orders);
return { ok: true };
}),
/** Add a symbol to a watchlist (defaults to 'default' watchlist). */
addSymbol: publicProcedure
.input(z.object({
symbol: z.string().toUpperCase(),
watchlistName: z.string().optional(),
notes: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { addSymbol, materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts');
const added = addSymbol(ctx.db, userId, input.symbol, input.notes, input.watchlistName);
if (added) {
materializeClassificationWatchlists(ctx.db, userId);
await ctx.cache.subscribe(input.symbol, 'equity');
queueSecFetch(ctx.db, input.symbol);
}
return { added };
}),
/** Remove a symbol from a watchlist (defaults to 'default'). */
removeSymbol: publicProcedure
.input(z.object({
symbol: z.string().toUpperCase(),
watchlistName: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { removeSymbol, materializeClassificationWatchlists } = await import('../db/watchlistRepository.ts');
const removed = removeSymbol(ctx.db, userId, input.symbol, input.watchlistName);
if (removed) materializeClassificationWatchlists(ctx.db, userId);
return { removed };
}),
/** Move a symbol from one watchlist to another (both default to 'default'). */
moveSymbol: publicProcedure
.input(z.object({
symbol: z.string().toUpperCase(),
fromWatchlist: z.string().optional(),
toWatchlist: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId ?? 'anonymous';
const { moveSymbol } = await import('../db/watchlistRepository.ts');
const moved = moveSymbol(ctx.db, userId, input.symbol, input.fromWatchlist ?? 'default', input.toWatchlist ?? 'default');
return { moved };
}),
});
// ─── Portfolio Router (Slice 10) ──────────────────────────────────────────────
const portfolioRouter = router({
/** List all open holdings. */
holdings: publicProcedure
.query(async ({ ctx }) => {
const userId = ctx.userId ?? 'anonymous';
const { listHoldings } = await import('../db/portfolioRepository.ts');
return listHoldings(ctx.db, userId);
}),
/** Add or accumulate a holding. */
addHolding: 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 };
}),
/** List open option legs (user book, not chain cache). */
optionLegs: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.userId as string;
const { listOptionLegs } = await import('../db/portfolioOptionRepository.ts');
return listOptionLegs(ctx.db, userId);
}),
/** Record a new option leg. */
addOptionLeg: protectedProcedure
.input(z.object({
underlying: z.string().min(1),
right: z.enum(['call', 'put']),
side: z.enum(['long', 'short']),
strike: z.number().positive(),
expiry: z.string().min(4),
contracts: z.number().positive(),
premium: z.number().min(0),
multiplier: z.number().positive().optional(),
role: z.enum(['long_call', 'long_put', 'covered_call', 'cash_secured_put', 'other']).optional(),
note: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const { addOptionLeg, listOptionLegs } = await import('../db/portfolioOptionRepository.ts');
const id = addOptionLeg(ctx.db, userId, {
underlying: input.underlying,
right: input.right,
side: input.side,
strike: input.strike,
expiry: input.expiry,
contracts: input.contracts,
premium: input.premium,
multiplier: input.multiplier,
role: input.role,
note: input.note ?? null,
});
const legs = listOptionLegs(ctx.db, userId);
return { id, legs };
}),
/** Soft-close an option leg. */
removeOptionLeg: protectedProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const userId = ctx.userId as string;
const { removeOptionLeg } = await import('../db/portfolioOptionRepository.ts');
const removed = removeOptionLeg(ctx.db, userId, input.id);
return { removed };
}),
});
// ─── Options Router (Slice 15 / M3) ────────────────────────────────────────
const optionsRouter = router({
/** Full options chain for a symbol + optional expiry. */
chain: publicProcedure
.input(z.object({ symbol: z.string().min(1), expiry: z.string().optional() }))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const adapter = new OptionsAdapter();
// If no expiry specified, fetch expiry dates and pick nearest
let expiry: string | undefined;
if (input.expiry) {
expiry = input.expiry;
} else {
try {
const dates = await adapter.expiryDates(symbol);
if (dates.length === 0) {
return { symbol, expiration: null, rows: [] as OptionChainRow[] };
}
// Pick the nearest expiry date
expiry = dates.sort()[0];
} catch {
return { symbol, expiration: null, rows: [] as OptionChainRow[] };
}
}
try {
const chain = await adapter.chain(symbol, expiry);
return { symbol, expiration: chain.expiration, rows: chain.rows };
} catch {
return { symbol, expiration: expiry ?? null, rows: [] as OptionChainRow[] };
}
}),
/** Greeks for a specific option (strike + expiry). */
greeks: publicProcedure
.input(z.object({ symbol: z.string().min(1), expiry: z.string().optional(), strike: z.number().optional() }))
.query(async ({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const adapter = new OptionsAdapter();
// If no expiry specified, fetch first available
let expiry: string | undefined;
if (input.expiry) {
expiry = input.expiry;
} else {
try {
const dates = await adapter.expiryDates(symbol);
if (dates.length === 0) {
return { symbol, greeks: null as OptionGreeks | null };
}
expiry = dates.sort()[0];
} catch {
return { symbol, greeks: null as OptionGreeks | null };
}
}
try {
const key = `yfinance:greeks:${symbol}:${expiry}:${input.strike ?? 0}`;
const result = await adapter.fetchOne(key);
const row = result.value as OptionChainRow | null;
return {
symbol,
greeks: row?.greeks ?? null,
strike: row?.strike ?? input.strike ?? null,
right: row?.right ?? null,
lastPrice: row?.lastPrice ?? null,
impliedVolatility: row?.impliedVolatility ?? null,
};
} catch {
return { symbol, greeks: null as OptionGreeks | null };
}
}),
});
const reportsRouter = router({
/** Generate a research note report. */
generate: protectedProcedure
.input(z.object({
scope: z.enum(['symbol', 'watchlist', 'portfolio', 'rotation', 'sizing_year', 'risk_posture']),
symbol: z.string().optional(),
data: z.record(z.string(), z.unknown()).optional().default({}),
}))
.query(async ({ ctx, input }) => {
const { generateReport } = await import('../reports/ReportRunner.ts');
return generateReport({
scope: input.scope,
symbol: input.symbol,
data: input.data as Record<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 as OptionsUnlockState, input.toState as OptionsUnlockState, 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');
const input: Record<string, number> = {};
try {
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; }
} catch { /* no cached GDP data */ }
try {
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; }
} catch { /* no cached CPI data */ }
return classifyRegime(input);
}),
commentary: publicProcedure.query(async ({ ctx }) => {
const { classifyRegime, generateMacroCommentary } = await import('../macro/MacroRegime.ts');
const input: Record<string, number> = {};
try {
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; }
} catch { /* no cached GDP data */ }
try {
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; }
} catch { /* no cached CPI data */ }
try {
const unrateEntry = await ctx.cache.get<unknown>('fred:series:UNRATE');
if (unrateEntry?.value) { const obs = (unrateEntry.value as any).observations; if (obs?.length) input.unemployment = obs[obs.length - 1].value; }
} catch { /* no cached unemployment data */ }
try {
const gs10Entry = await ctx.cache.get<unknown>('fred:series:GS10');
if (gs10Entry?.value) { const obs = (gs10Entry.value as any).observations; if (obs?.length) input.treasury10Y = obs[obs.length - 1].value; }
} catch { /* no cached 10Y data */ }
const classification = classifyRegime(input);
return generateMacroCommentary(classification);
}),
regimeHistory: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.prepare('SELECT * FROM regime_history ORDER BY date DESC LIMIT 50').all();
}),
});
// ─── Thesis Monitor Router (Slice 21) ───────────────────────────────────────
const thesisMonitorRouter = router({
assess: protectedProcedure
.input(z.object({ symbol: z.string() }))
.query(async ({ ctx, input }) => {
const { assessThesis } = await import('../thesis/ThesisMonitor.ts');
const userId = ctx.userId as string;
const thesis = ctx.db.prepare('SELECT * FROM theses WHERE user_id=? AND symbol=? ORDER BY updated_at DESC LIMIT 1').get(userId, input.symbol.toUpperCase()) as { statement: string; invalidation_criteria: string } | undefined;
if (!thesis) throw new TRPCError({ code: 'NOT_FOUND', message: 'No thesis found for this symbol.' });
const events: ThesisEvent[] = [];
// Gather events from DB (form4, 13f, etc.)
return assessThesis({
symbol: input.symbol.toUpperCase(),
statement: thesis.statement,
invalidationCriteria: JSON.parse(thesis.invalidation_criteria) as string[],
createdAt: new Date().toISOString(),
}, events);
}),
timeline: protectedProcedure.query(async ({ ctx }) => {
const { buildTimeline } = await import('../thesis/ThesisMonitor.ts');
const userId = ctx.userId as string;
const theses = ctx.db.prepare('SELECT * FROM theses WHERE user_id=?').all(userId) as Array<Record<string, unknown>>;
// For each thesis, get assessment (simplified)
return { timeline: [], theses };
}),
});
// ─── X/Cookie + Reddit Router (Slice 16) ────────────────────────────────────
const xRouter = router({
// ---------------------------------------------------------------------------
// X / BirdClaw — cashtag search, trusted timeline, combined feed.
// Credentials are loaded from DB per-request (never hardcoded). When absent the
// endpoint returns an empty/`configured:false` response instead of crashing.
// ---------------------------------------------------------------------------
// Per-symbol feed: tracked poster AND ticker must both match.
// (1) author is in x_accounts for this symbol, (2) post mentions $TICKER or bare TICKER.
// Global cashtag noise (random accounts) is excluded.
feed: protectedProcedure
.input(z.object({ symbol: z.string().min(1).max(20), limit: z.number().min(5).max(200).optional().default(50), cursor: z.string().optional() }))
.query(async ({ ctx, input }) => {
const creds = await loadXCredentials(ctx);
if (!creds) return { cashtagPosts: [], accountPosts: [], configured: false as const };
const sym = input.symbol.toUpperCase();
const sinceMs = Date.now() - 30 * 24 * 60 * 60 * 1000;
const handles = (ctx.db.prepare(
'SELECT handle FROM x_accounts WHERE symbol=?',
).all(sym) as Array<{ handle: string }>).map((h) => h.handle.toLowerCase());
type PostRow = {
post_id: string; author_handle: string; cashtag: string | null; body_text: string | null;
posted_at: string; engagement: number; sentiment_score?: number | null; attribution?: string | null;
};
if (handles.length === 0) {
return {
cashtagPosts: [],
accountPosts: [],
nextCursor: null,
configured: true as const,
health: null,
};
}
// Escape for RegExp (tickers are usually alnum but keep safe).
const esc = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// $TICKER or bare TICKER as a token (not a substring of another word).
const tickerRe = new RegExp(`(?:^|[^A-Za-z0-9])\\$?${esc}(?=[^A-Za-z0-9]|$)`, 'i');
const mentionsTicker = (p: PostRow): boolean => {
const tag = (p.cashtag ?? '').replace(/^\$/, '').toUpperCase();
if (tag === sym) return true;
return tickerRe.test(p.body_text ?? '');
};
const inWindow = (p: PostRow) => {
const t = Date.parse(p.posted_at);
return !Number.isFinite(t) || t >= sinceMs;
};
// Only posts from accounts explicitly paired with this symbol.
let filtered = (ctx.db.prepare(
`SELECT post_id, author_handle, cashtag, body_text, posted_at, engagement, sentiment_score, attribution
FROM x_cookie_posts
WHERE lower(author_handle) IN (${handles.map(() => '?').join(',')})
ORDER BY posted_at DESC
LIMIT 800`,
).all(...handles) as PostRow[]).filter((p) => inWindow(p) && mentionsTicker(p));
filtered.sort((a, b) => Date.parse(b.posted_at) - Date.parse(a.posted_at));
if (input.cursor) {
const cMs = Date.parse(input.cursor);
filtered = filtered.filter((p) => {
const t = Date.parse(p.posted_at);
if (Number.isFinite(cMs) && Number.isFinite(t)) return t < cMs;
return p.posted_at < input.cursor!;
});
}
const page = filtered.slice(0, input.limit);
const nextCursor = page.length === input.limit ? page[page.length - 1].posted_at : null;
// Background refresh: timelines for tracked handles only (cashtag search is optional ingest, not feed source).
try {
for (const h of handles) {
try { ctx.queue.queue(`x:timeline:${h}`); } catch { /* ignore */ }
}
} catch { /* ignore */ }
const healthRow = ctx.db.prepare('SELECT healthy, last_error, updated_at FROM x_credentials WHERE id=?').get('singleton') as { healthy?: number; last_error?: string | null } | undefined;
return {
cashtagPosts: page,
accountPosts: [],
nextCursor,
configured: true as const,
health: healthRow ? {
healthy: healthRow.healthy === 1 ? ('healthy' as const) : ('degraded' as const),
lastError: healthRow.last_error ?? null,
} : null,
};
}),
// Raw cashtag search (no per-account merge).
cashtag_search: protectedProcedure
.input(z.object({ symbol: z.string().min(1).max(20), limit: z.number().min(5).max(200).optional().default(20) }))
.query(async ({ ctx, input }) => {
const creds = await loadXCredentials(ctx);
if (!creds) return { posts: [], configured: false as const };
const { XCookieAdapter } = await import('../adapters/XCookieAdapter.ts');
const adapter = new XCookieAdapter(creds, (health: XCookieHealth) => updateXHealth(ctx.db, health.sourceStatus, health.lastError ?? undefined));
try {
const posts = (await adapter.cashtagSearch(input.symbol, {})).value;
return { posts, configured: true as const };
} catch (e) {
throw new TRPCError({ code: 'BAD_GATEWAY', message: e instanceof Error ? e.message : 'X request failed.' });
}
}),
// Single trusted account timeline.
timeline: protectedProcedure
.input(z.object({ handle: z.string().min(1).regex(/^[a-zA-Z0-9_]+$/), limit: z.number().min(5).max(200).optional().default(20) }))
.query(async ({ ctx, input }) => {
const creds = await loadXCredentials(ctx);
if (!creds) return { posts: [], configured: false as const };
const { XCookieAdapter } = await import('../adapters/XCookieAdapter.ts');
const adapter = new XCookieAdapter(creds, (health: XCookieHealth) => updateXHealth(ctx.db, health.sourceStatus, health.lastError ?? undefined));
try {
const posts = (await adapter.trustedTimeline(input.handle, {})).value;
return { posts, configured: true as const };
} catch (e) {
throw new TRPCError({ code: 'BAD_GATEWAY', message: e instanceof Error ? e.message : 'X request failed.' });
}
}),
// Per-symbol tracked account list.
accountsForSymbol: protectedProcedure
.input(z.object({ symbol: z.string().min(1).max(20) }))
.query(({ ctx, input }) => {
const rows = ctx.db.prepare('SELECT id, handle, COALESCE(label, \'\' ) AS label FROM x_accounts WHERE symbol=? ORDER BY handle').all(input.symbol.toUpperCase()) as Array<{id: string; handle: string; label: string}>;
return (rows ?? []);
}),
});
const redditRouter = router({
subreddit: protectedProcedure
.input(z.object({ subreddit: z.string(), limit: z.number().min(1).max(100).optional().default(25) }))
.query(async ({ ctx, input }) => {
const { RedditAdapter } = await import('../adapters/RedditAdapter.ts');
const adapter = new RedditAdapter();
try {
const result = await adapter.subredditPosts(input.subreddit, undefined, {});
return { posts: result.value, isStale: false };
} catch (e) {
throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message });
}
}),
search: protectedProcedure
.input(z.object({ q: z.string(), limit: z.number().min(1).max(100).optional().default(25) }))
.query(async ({ ctx, input }) => {
const { RedditAdapter } = await import('../adapters/RedditAdapter.ts');
const adapter = new RedditAdapter();
try {
const result = await adapter.searchPosts(input.q, {});
return { posts: result.value, isStale: false };
} catch (e) {
throw new TRPCError({ code: 'NOT_FOUND', message: (e as Error).message });
}
}),
});
// Slice 8 — Institutional Dashboard Rollup (read-only, no trade actions).
// ─── Sizing Router (P0: wire-sizing-risk-trpc-m20) ────────────────────────────
// Pure SizingEngine behind tRPC. Outputs math + plain-English layers only (ADR-0007).
const sizingRouter = router({
compute: protectedProcedure
.input(z.object({
symbol: z.string().min(1).max(20),
tier: z.enum(['A_STAR', 'A', 'B', 'C']),
riskFraction: z.number().positive().max(0.25),
stopPerShare: z.number().positive(),
equity: z.number().positive(),
complexity: z.enum(['beginner', 'intermediate', 'advanced']).optional(),
regime: z.enum(['trending-up', 'trending-down', 'range-bound']).default('range-bound'),
aStarUnlocked: z.boolean().optional(),
macroOverrideReason: z.string().min(3).max(500).optional(),
holdings: z.array(z.object({
symbol: z.string(),
shares: z.number(),
avgCost: z.number(),
cluster: z.string().default('uncategorized'),
})).optional(),
clusterCaps: z.record(z.string(), z.number()).nullable().optional(),
}))
.query(async ({ ctx, input }) => {
const { sizePosition } = await import('../sizing/SizingEngine.ts');
const userId = ctx.userId as string;
const user = ctx.db.prepare(
'SELECT complexity FROM users WHERE id=?',
).get(userId) as { complexity: string } | undefined;
const complexity = (input.complexity ?? user?.complexity ?? 'beginner') as
'beginner' | 'intermediate' | 'advanced';
// Prefer live portfolio when client did not pass holdings.
let holdings = input.holdings ?? [];
if (!input.holdings) {
const { listHoldings } = await import('../db/portfolioRepository.ts');
holdings = listHoldings(ctx.db, userId).map((h) => ({
symbol: h.symbol,
shares: h.shares,
avgCost: h.avg_cost,
cluster: 'uncategorized',
}));
}
const equity = input.equity;
const defaultCap = complexity === 'beginner' ? equity * 0.25
: complexity === 'intermediate' ? equity * 0.40
: null;
const clusterCaps = input.clusterCaps !== undefined
? input.clusterCaps
: (defaultCap !== null ? { uncategorized: defaultCap } : null);
const unlockRow = ctx.db.prepare(
"SELECT 1 AS ok FROM sizing_unlocks WHERE user_id=? AND unlock='tier_a_star' LIMIT 1",
).get(userId) as { ok: number } | undefined;
const aStarUnlocked = input.aStarUnlocked ?? !!unlockRow;
// Gentle-halt: when active, risk fraction is educational-only but we still
// surface the halt flag so the UI can teach the circuit breaker.
const { isHalted } = await import('../risk/haltCircuitBreaker.ts');
const halted = isHalted(ctx.db, userId);
const result = sizePosition(
{
symbol: input.symbol.toUpperCase(),
tier: input.tier,
riskFraction: halted ? Math.min(input.riskFraction, 0.005) : input.riskFraction,
stopPerShare: input.stopPerShare,
},
{ equity, complexity },
{
holdings,
regime: input.regime,
clusterCaps,
aStarUnlocked,
},
{
aStarUnlocked,
macroOverride: input.macroOverrideReason
? { reason: input.macroOverrideReason }
: null,
},
);
return {
...result,
halted,
footer: 'Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions.',
};
}),
});
// ─── Risk Posture Router (P0: wire-sizing-risk-trpc-m20 / M20) ────────────────
const riskRouter = router({
/** Full RiskPosture from portfolio + account equity inputs. */
posture: protectedProcedure
.input(z.object({
equity: z.number().positive(),
peakEquity: z.number().positive().optional(),
drawdownTolerancePct: z.number().positive().max(100).optional(),
regime: z.enum(['trending-up', 'trending-down', 'range-bound']).optional(),
/** Optional plan-level stops/targets keyed by symbol for asymmetry math. */
plans: z.array(z.object({
symbol: z.string(),
stopPrice: z.number().optional(),
rewardTarget: z.number().optional(),
cluster: z.string().optional(),
})).optional(),
}))
.query(async ({ ctx, input }) => {
const { assessRisk, ADR_0007_FOOTER } = await import('../risk/RiskEngine.ts');
const { isHalted, getHaltRecord, triggerHalt } = await import('../risk/haltCircuitBreaker.ts');
const { listHoldings } = await import('../db/portfolioRepository.ts');
const userId = ctx.userId as string;
const user = ctx.db.prepare(
'SELECT complexity, drawdown_tolerance FROM users WHERE id=?',
).get(userId) as { complexity: string; drawdown_tolerance: number | null } | undefined;
const complexity = (user?.complexity ?? 'beginner') as
'beginner' | 'intermediate' | 'advanced';
// Onboarding stores drawdown as a signed percent (e.g. -20); RiskEngine uses magnitude.
const drawdownTolerancePct = input.drawdownTolerancePct
?? (typeof user?.drawdown_tolerance === 'number'
? Math.abs(user.drawdown_tolerance)
: 20);
const planBySymbol = new Map(
(input.plans ?? []).map((p) => [p.symbol.toUpperCase(), p]),
);
const holdings = listHoldings(ctx.db, userId);
const portfolio = holdings.map((h) => {
const plan = planBySymbol.get(h.symbol.toUpperCase());
return {
symbol: h.symbol,
shares: h.shares,
avgCost: h.avg_cost,
cluster: plan?.cluster ?? 'uncategorized',
stopPrice: plan?.stopPrice,
rewardTarget: plan?.rewardTarget,
};
});
const { listOptionLegs } = await import('../db/portfolioOptionRepository.ts');
const {
assessOptionRiskContribution,
mergeOptionCapitalIntoClusters,
} = await import('../risk/optionRiskContribution.ts');
const optionLegs = listOptionLegs(ctx.db, userId);
const equity = input.equity;
const peakEquity = input.peakEquity ?? equity;
const defaultCap = complexity === 'beginner' ? equity * 0.25
: complexity === 'intermediate' ? equity * 0.40
: null;
const clusterCaps = defaultCap !== null ? { uncategorized: defaultCap } as Record<string, number> : null;
const posture = assessRisk({
portfolio,
account: { equity, drawdownTolerancePct, complexity },
peakEquity,
regime: input.regime,
sizingContext: { clusterCaps },
});
const optionRisk = assessOptionRiskContribution(
optionLegs,
holdings.map((h) => ({ symbol: h.symbol, shares: h.shares })),
equity,
);
const clusterExposure = mergeOptionCapitalIntoClusters(
posture.clusterExposure,
optionRisk.capitalByUnderlying,
true,
);
const recommendedActions = [
...posture.recommendedActions,
...optionRisk.recommendations,
];
// Persist gentle-halt when math says breached (24h cooldown).
if (posture.halted && !isHalted(ctx.db, userId)) {
triggerHalt(ctx.db, userId, 'max_drawdown_tolerance_breach');
}
const haltRecord = getHaltRecord(ctx.db, userId);
const haltActive = isHalted(ctx.db, userId);
return {
...posture,
clusterExposure,
recommendedActions,
halted: posture.halted || haltActive,
halt: haltRecord && haltActive
? {
haltedUntil: haltRecord.halted_until,
triggeredBy: haltRecord.triggered_by,
ts: haltRecord.ts,
}
: null,
holdingsCount: holdings.length,
totalExposureUsd: portfolio.reduce((s, p) => s + p.shares * p.avgCost, 0),
options: {
legsCount: optionRisk.legsCount,
premiumAtRiskUsd: optionRisk.premiumAtRiskUsd,
cashReservedUsd: optionRisk.cashReservedUsd,
creditReceivedUsd: optionRisk.creditReceivedUsd,
capitalCommittedUsd: optionRisk.capitalCommittedUsd,
uncoveredShortCallCount: optionRisk.uncoveredShortCallCount,
byUnderlying: optionRisk.byUnderlying,
},
footer: ADR_0007_FOOTER,
};
}),
haltStatus: protectedProcedure.query(async ({ ctx }) => {
const { isHalted, getHaltRecord } = await import('../risk/haltCircuitBreaker.ts');
const userId = ctx.userId as string;
const active = isHalted(ctx.db, userId);
const record = getHaltRecord(ctx.db, userId);
return {
active,
haltedUntil: active && record ? record.halted_until : null,
triggeredBy: active && record ? record.triggered_by : null,
};
}),
});
// ─── Emotion Logger Router (Slice: emotion-logger-storage) ──────────────────────
// Emotion Logger Router (Slice: emotion-logger-storage)
// ---------------------------------------------------------------------------
const emotionLoggerRouter = router({
/** Add an emotion log entry for a trade execution. */
add: protectedProcedure
.input(z.object({
tradeExecutionId: z.string(),
priceAtEvent: z.number().optional(),
emotion: z.string(),
note: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { addEmotionLog } = await import('../db/emotionLogRepository.ts');
try {
const log = addEmotionLog(ctx.db, {
tradeExecutionId: input.tradeExecutionId,
priceAtEvent: input.priceAtEvent,
emotion: input.emotion,
note: input.note,
});
return { success: true, log };
} catch (e) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: (e as Error).message });
}
}),
/** Get all emotion logs for a trade execution. */
getByTrade: protectedProcedure
.input(z.object({ tradeExecutionId: z.string() }))
.query(async ({ ctx, input }) => {
const { getEmotionLogsByTrade } = await import('../db/emotionLogRepository.ts');
try {
const logs = getEmotionLogsByTrade(ctx.db, input.tradeExecutionId);
return { logs };
} catch (e) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: (e as Error).message });
}
}),
/** Delete an emotion log by id. */
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const { deleteEmotionLog } = await import('../db/emotionLogRepository.ts');
try {
deleteEmotionLog(ctx.db, input.id);
return { success: true };
} catch (e) {
throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: (e as Error).message });
}
}),
});
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,
sizing: sizingRouter, risk: riskRouter,
});
export type AppRouter = typeof appRouter;