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

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