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.
5660 lines
241 KiB
TypeScript
5660 lines
241 KiB
TypeScript
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;
|
||
|