Files
investor-flow/app/server/src/trpc/router.ts
T
Investor Flow Build 0bc7b636e5 fix: dedup fund_capture alerts for same-date sibling records and add clear-all
fund_capture alerts for a symbol with multiple capture records sharing one
as_of re-fired every batch cycle: the single comparison-state slot
ping-ponged between sibling evidence URLs, and the pre-check dedup key
never matched the stored buildDedupKey format. Track a seen-set of URLs in
comparison state and align dedup-key construction with buildDedupKey.
Also add alerts.clearAll (hard delete) with an immediate-refresh bell.
2026-08-12 22:39:19 -04:00

5660 lines
241 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, confluenceslotById } 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 };
}),
/** 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 };
}),
/** Permanently delete all alert events for the current user. */
clearAll: protectedProcedure
.mutation(async ({ ctx }) => {
const userId = ctx.userId as string;
ctx.db.prepare('DELETE FROM alert_events WHERE user_id = ?').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 };
}),
});
// ─── 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.',
};
}),
/** Signal fire history for a symbol (last 6 months). Used by the confluence chart. */
signalHistory: publicProcedure
.input(z.object({ symbol: z.string().min(1).max(12) }))
.query(({ ctx, input }) => {
const symbol = input.symbol.toUpperCase();
const repo = new ConfluenceRepository(ctx.db);
const allFires = repo.listSignalsForSymbol(symbol);
// Filter to last 6 months
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - 6);
const cutoffStr = cutoff.toISOString();
const fires = allFires
.filter((f) => f.firedAt >= cutoffStr)
.map((f) => {
const meta = confluenceslotById.get(f.slotId);
return {
slotId: f.slotId,
slotName: meta?.name ?? f.slotId,
body: (meta?.body ?? 'bull') as 'bull' | 'bear' | 'exit',
firedAt: f.firedAt,
qualityAtFire: f.qualityAtFire,
};
});
return { fires };
}),
/** 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;