2026-06-29 17:40:42 -04:00
import { initTRPC , TRPCError } from '@trpc/server' ;
import { z } from 'zod' ;
import { randomUUID } from 'node:crypto' ;
import type { Context } from './context.ts' ;
2026-06-29 21:17:57 -04:00
import { hashPassword , verifyPassword , createSession , clearCookie , oauthStateCookie , verifyOAuthState , OAUTH_STATE_COOKIE } from './context.ts' ;
2026-06-29 19:32:09 -04:00
import { generateBase32Secret , totp as computeTotp , verifyTotp , otpauthUrl } from '../auth/totp.ts' ;
import { generateBackupCodes , hashBackupCode } from '../auth/backup-codes.ts' ;
2026-06-29 21:17:57 -04:00
import { buildAuthorizeUrl , generateState , exchangeCode , type OAuthProvider } from '../auth/oauth.ts' ;
2026-06-29 21:31:59 -04:00
import { STARTER_WATCHLIST , defaultDrawdownTolerancePct , defaultRiskTolerance , ONBOARDING_DISCLAIMER , type Complexity } from '../onboarding/starter.ts' ;
2026-06-29 17:40:42 -04:00
import type { Quote , PriceCandle , SymbolMeta } from '../cache/CacheRepository.ts' ;
2026-06-29 22:57:34 -04:00
import { emaFromCandles , rsi as rsiFn , relativeVolume } from '../analysis/indicators.ts' ;
2026-07-05 08:50:12 -04:00
import { listUsers , resetPassword , gdprExport , queueHealth , resetQueueBackoff , NotOwnerError } from '../admin/admin.ts' ;
import { EdgarAdapter } from '../adapters/EdgarAdapter.ts' ;
import { OptionsAdapter , parseOptionChainRows } from '../adapters/OptionsAdapter.ts' ;
import type { OptionChainRow , OptionGreeks } from '../adapters/OptionsAdapter.ts' ;
2026-06-29 17:40:42 -04:00
const t = initTRPC . context < Context >(). create ();
const router = t . router ;
const publicProcedure = t . procedure ;
2026-06-29 19:32:09 -04:00
const protectedProcedure = publicProcedure . use (({ ctx , next }) => {
if ( ! ctx . userId ) throw new TRPCError ({ code : 'UNAUTHORIZED' , message : 'Sign in required.' });
return next ({ ctx });
});
2026-07-05 08:50:12 -04:00
// 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 });
});
2026-06-29 21:17:57 -04:00
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 };
}
2026-06-29 17:40:42 -04:00
const authRouter = router ({
signup : publicProcedure
. input ( z . object ({ email : z.string (). email (), password : z.string (). min ( 8 ) }))
. mutation ( async ({ ctx , input }) => {
const email = input . email . toLowerCase ();
const existing = ctx . db . prepare ( 'SELECT id FROM users WHERE email=?' ). get ( email );
if ( existing ) throw new TRPCError ({ code : 'CONFLICT' , message : 'That email is already registered.' });
const userId = randomUUID ();
ctx . db . prepare ( 'INSERT INTO users (id,email,pw_hash,created_at) VALUES (?,?,?,?)' ). run ( userId , email , hashPassword ( input . password ), new Date (). toISOString ());
const { cookie } = createSession ( ctx . db , userId );
ctx . resHeaders . append ( 'Set-Cookie' , cookie );
return { userId };
}),
login : publicProcedure
2026-06-29 19:32:09 -04:00
. input ( z . object ({ email : z.string (). email (), password : z.string (), totp : z.string (). optional () }))
2026-06-29 17:40:42 -04:00
. mutation ( async ({ ctx , input }) => {
const email = input . email . toLowerCase ();
2026-06-29 19:32:09 -04:00
const row = ctx . db . prepare ( 'SELECT id, pw_hash, is_2fa_enabled, totp_secret FROM users WHERE email=?' ). get ( email ) as { id : string ; pw_hash : string ; is_2fa_enabled : number ; totp_secret : string | null } | undefined ;
2026-06-29 17:40:42 -04:00
if ( ! row || ! verifyPassword ( input . password , row . pw_hash )) throw new TRPCError ({ code : 'UNAUTHORIZED' , message : 'Invalid email or password.' });
2026-06-29 19:32:09 -04:00
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.' });
}
}
2026-06-29 17:40:42 -04:00
const { cookie } = createSession ( ctx . db , row . id );
ctx . resHeaders . append ( 'Set-Cookie' , cookie );
return { userId : row.id };
}),
logout : publicProcedure.mutation (({ ctx }) => {
ctx . resHeaders . append ( 'Set-Cookie' , clearCookie ());
return { ok : true };
}),
me : publicProcedure.query (({ ctx }) => {
if ( ! ctx . userId ) return null ;
const u = ctx . db . prepare ( 'SELECT id,email,complexity,risk_tolerance,convexity_posture FROM users WHERE id=?' ). get ( ctx . userId ) as { id : string ; email : string ; complexity : string ; risk_tolerance : string ; convexity_posture : string } | undefined ;
2026-06-29 21:31:59 -04:00
const wl = ctx . db . prepare ( 'SELECT 1 FROM watchlists WHERE owner_id=? LIMIT 1' ). get ( ctx . userId );
return u ? { userId : u.id , email : u.email , complexity : u.complexity , riskTolerance : u.risk_tolerance , convexityPosture : u.convexity_posture , onboarded : !! wl } : null ;
2026-06-29 17:40:42 -04:00
}),
2026-06-29 21:17:57 -04:00
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 };
2026-06-29 19:32:09 -04:00
}),
2026-06-29 21:17:57 -04:00
// 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 () }))
2026-06-29 19:32:09 -04:00
. mutation ( async ({ ctx , input }) => {
2026-06-29 21:17:57 -04:00
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 };
2026-06-29 19:32:09 -04:00
}),
2026-06-29 17:40:42 -04:00
});
2026-06-29 21:31:59 -04:00
const onboardingRouter = router ({
// Public: the starter watchlist + disclaimer shown in the wizard before completing.
starter : publicProcedure.query (() => ({ watchlist : STARTER_WATCHLIST , disclaimer : ONBOARDING_DISCLAIMER })),
// Protected: write complexity/risk/drawdown + first watchlist + optional portfolio; subscribe symbols to demand.
complete : protectedProcedure
. input ( z . object ({
complexity : z.enum ([ 'beginner' , 'intermediate' , 'advanced' ]),
riskTolerance : z.enum ([ 'conservative' , 'moderate' , 'aggressive' ]). optional (),
drawdownTolerancePct : z.number (). optional (),
firstWatchlistSymbols : z.array ( z . string ()). optional (),
portfolio : z.array ( z . object ({ symbol : z . string (), qty : z.number (), avgCost : z.number (), acquiredAt : z.string () })). optional (),
}))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId as string ;
const complexity = input . complexity as Complexity ;
const riskTolerance = input . riskTolerance ?? defaultRiskTolerance ( complexity );
const drawdown = input . drawdownTolerancePct ?? defaultDrawdownTolerancePct ( complexity );
ctx . db . prepare ( 'UPDATE users SET complexity=?, risk_tolerance=?, drawdown_tolerance=? WHERE id=?' ). run ( complexity , riskTolerance , drawdown , userId );
const symbols = input . firstWatchlistSymbols ?? STARTER_WATCHLIST . map (( s ) => s . symbol );
const wlId = randomUUID ();
ctx . db . prepare ( 'INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?,?,?,?,?,?)' ). run ( wlId , userId , 'Starter' , JSON . stringify ( symbols ), new Date (). toISOString (), 0 );
for ( const sym of symbols ) {
const kind = ( STARTER_WATCHLIST . find (( s ) => s . symbol === sym ) ? . tickerKind ?? 'equity' ) as 'equity' | 'crypto' | 'etf' | 'index' ;
await ctx . cache . subscribe ( sym , kind );
}
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 };
}),
});
2026-06-29 17:40:42 -04:00
const marketRouter = router ({
snapshot : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
2026-06-29 21:17:57 -04:00
const k = { quote : `yfinance:quote: ${ symbol } ` , candles : `yfinance:candles: ${ symbol } :1d` , sector : `yfinance:symbol: ${ symbol } ` };
2026-06-29 17:40:42 -04:00
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 ) },
};
}),
2026-06-29 22:57:34 -04:00
candles : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ), timeframe : z.enum ([ '1d' , '1wk' ]). default ( '1d' ) }))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
const key = `yfinance:candles: ${ symbol } : ${ input . timeframe } ` ;
const entry = await ctx . cache . get < PriceCandle [] >( key );
return { symbol , timeframe : input.timeframe , candles : ( entry . value ?? []), isStale : entry.isStale };
}),
indicators : publicProcedure
. input ( z . object ({
symbol : z . string (). min ( 1 ),
timeframe : z.enum ([ '1d' , '1wk' ]). default ( '1d' ),
periods : z.object ({
ema : z.array ( z . number (). int ()). default ([ 9 , 21 , 50 , 200 ]),
rsi : z.number (). int (). default ( 14 ),
relvol : z.number (). int (). default ( 20 ),
}). default (() => ({ ema : [ 9 , 21 , 50 , 200 ], rsi : 14 , relvol : 20 } as const )),
}))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
const key = `yfinance:candles: ${ symbol } : ${ input . timeframe } ` ;
const entry = await ctx . cache . get < PriceCandle [] >( key );
const candles = entry . value ?? [];
const periods = input . periods ?? { ema : [ 9 , 21 , 50 , 200 ], rsi : 14 , relvol : 20 };
const closes = candles . map (( c ) => c . c );
const volumes = candles . map (( c ) => c . v );
const emaObj : Record < string , ( number | undefined )[] > = {};
for ( const p of periods . ema ) {
emaObj [ String ( p )] = emaFromCandles ( candles , 'adjClose' , p );
}
return {
ema : emaObj ,
rsi : rsiFn ( closes , periods . rsi ),
relativeVolume : relativeVolume ( volumes , periods . relvol ),
};
}),
2026-06-29 17:40:42 -04:00
});
2026-07-05 08:50:12 -04:00
// ---------------------------------------------------------------------------
// admin.* (Slice 25) — operator tooling, auth-gated to is_admin=1.
// ---------------------------------------------------------------------------
const adminRouter = router ({
usersList : adminProcedure.query (({ ctx }) => listUsers ( ctx . db )),
resetPassword : adminProcedure
. input ( z . object ({ email : z.string (). email (), tempPassword : z.string (). min ( 8 ) }))
. mutation (({ ctx , input }) => {
try {
return resetPassword ( ctx . db , ctx . userId , input . email , hashPassword ( input . tempPassword ));
} catch ( e ) {
if ( e instanceof NotOwnerError ) throw new TRPCError ({ code : 'FORBIDDEN' , message : e.message });
throw new TRPCError ({ code : 'NOT_FOUND' , message : 'User not found.' });
}
}),
gdprExport : adminProcedure
. input ( z . object ({ userId : z.string (). uuid () }))
. mutation (({ ctx , input }) => {
try {
return gdprExport ( ctx . db , ctx . userId , input . userId );
} catch ( e ) {
if ( e instanceof NotOwnerError ) throw new TRPCError ({ code : 'FORBIDDEN' , message : e.message });
throw e ;
}
}),
queueHealth : adminProcedure.query (({ ctx }) => queueHealth ( ctx . db )),
resetBackoff : adminProcedure
. input ( z . object ({ sourceKind : z.string (). regex ( /^[a-z0-9_]+$/i ) }))
. mutation (({ ctx , input }) => resetQueueBackoff ( ctx . db , ctx . userId , input . sourceKind )),
});
// 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 as string ;
// Import lazily to avoid circular deps.
const { DashboardRollupEngine } = await import ( '../analysis/dashboardRollup.ts' );
const { InstitutionFlowEngine } = await import ( '../analysis/institutionFlowEngine.ts' );
// Use a no-op EdgarAdapter for the rollup (we query DB directly).
const noopEdgar = {
sourceKind : 'sec' as const ,
async fetchOne ( _key : string ) { throw new Error ( 'not used' ); },
async filings_index ( _cik : string , _opts? : any ) { return { value : [], ttlClass : 'daily_permanent' as const , provenance : { fetchedAt : new Date (). toISOString (), sourceKind : 'sec' as const } }; },
async company_facts ( _cik : string ) { return { value : {}, ttlClass : 'daily_permanent' as const , provenance : { fetchedAt : new Date (). toISOString (), sourceKind : 'sec' as const } }; },
async filer_cik_meta ( _cik : string ) { return { value : {}, ttlClass : 'daily_permanent' as const , provenance : { fetchedAt : new Date (). toISOString (), sourceKind : 'sec' as const } }; },
async full_text_search ( _q : string ) { return { value : [], ttlClass : 'daily_permanent' as const , provenance : { fetchedAt : new Date (). toISOString (), sourceKind : 'sec' as const } }; },
async form13f_holdings ( _cik : string , _accession : string ) { return { value : { holdings : [] }, ttlClass : 'daily_permanent' as const , provenance : { fetchedAt : new Date (). toISOString (), sourceKind : 'sec' as const } }; },
async form4_tx ( _cik : string , _accession : string ) { return { value : { transactions : [] }, ttlClass : 'daily_permanent' as const , provenance : { fetchedAt : new Date (). toISOString (), sourceKind : 'sec' as const } }; },
} as any ;
const flowEngine = new InstitutionFlowEngine ( noopEdgar );
const engine = new DashboardRollupEngine ( ctx . db , flowEngine );
return engine . computeRollup ( userId );
}),
});
// ─── Alerts Router (Slice 17) ────────────────────────────────────────────────
const alertsRouter = router ({
/** List alerts for the current user, newest first. */
list : protectedProcedure
. input ( z . object ({ limit : z.number (). min ( 1 ). max ( 100 ). optional (). default ( 50 ) }))
. query ( async ({ ctx , input }) => {
const userId = ctx . userId as string ;
const rows = ctx . db . prepare ( `
SELECT id, user_id, type, severity, title, description, symbol,
created_at, acknowledged, dedup_key, payload
FROM alert_events
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ?
` ). all ( userId , input . limit ) as Array < Record < string , unknown >>;
return rows . map (( r ) => ({
id : r.id ,
userId : r.user_id ,
type : r . type ,
severity : r.severity ,
title : r.title ,
description : r.description ,
symbol : r . symbol ?? undefined ,
createdAt : r.created_at ,
acknowledged : r.acknowledged === 1 || r . acknowledged === true ,
dedupKey : r.dedup_key ,
payload : typeof r . payload === 'string' ? JSON . parse ( r . payload as string ) : r . payload ,
}));
}),
/** Acknowledge an alert (mark as read). */
acknowledge : protectedProcedure
. input ( z . object ({ alertId : z.string () }))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId as string ;
const result = ctx . db . prepare (
'UPDATE alert_events SET acknowledged = 1 WHERE id = ? AND user_id = ?'
). run ( input . alertId , userId );
if ( result . changes === 0 ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Alert not found.' });
}
return { ok : true };
}),
/** Acknowledge all alerts for the current user. */
acknowledgeAll : protectedProcedure
. mutation ( async ({ ctx }) => {
const userId = ctx . userId as string ;
ctx . db . prepare (
'UPDATE alert_events SET acknowledged = 1 WHERE user_id = ? AND acknowledged = 0'
). run ( userId );
return { ok : true };
}),
/** Get unacknowledged alert count. */
unackedCount : protectedProcedure
. query ( async ({ ctx }) => {
const userId = ctx . userId as string ;
const row = ctx . db . prepare (
'SELECT COUNT(*) as count FROM alert_events WHERE user_id = ? AND acknowledged = 0'
). get ( userId ) as { count : number } | undefined ;
return { count : row?.count ?? 0 };
}),
});
// ─── Institutional Flow Router (Slice 7 / M4 + M5) ────────────────────────
interface InstitutionalFlowResult {
cusip : string ;
name : string ;
prevShares : number ;
currShares : number ;
delta : number ;
classification : string ;
holderClass? : string ;
}
interface InsiderStreamEvent {
reporter : string ;
relationship : string ;
securityTitle : string ;
transactionDate : string ;
transactionCode : string ;
shares : number ;
price : number ;
netDirection : string ;
is10b5Plan? : boolean ;
planDetails? : string ;
}
interface InsiderStreamSummary {
cik : string ;
events : InsiderStreamEvent [];
netShares : number ;
direction : string | null ;
transactionType : 'Informed' | 'Routine' ;
}
const institutionalRouter = router ({
/** Get institutional flow for a symbol (M4 per-symbol view). */
flow : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
// Query institution_filings for this symbol, grouped by filer_cik
const rows = ctx . db . prepare ( `
SELECT filer_cik, filer_name, symbol, form, shares, value_usd,
reported_quarter, filed_at
FROM institution_filings
WHERE symbol = ?
ORDER BY filed_at DESC
` ). all ( symbol ) as Array < {
filer_cik : string ;
filer_name : string | null ;
symbol : string ;
form : string ;
shares : number | null ;
value_usd : number | null ;
reported_quarter : string ;
filed_at : string ;
} > ;
if ( rows . length === 0 ) {
return { symbol , flow : [], quarters : [] };
}
// Group by filer_cik, then by reported_quarter to compute QoQ flow
const byCik = new Map < string , typeof rows >();
for ( const r of rows ) {
const arr = byCik . get ( r . filer_cik ) ?? [];
arr . push ( r );
byCik . set ( r . filer_cik , arr );
}
const allFlow : Array < {
filerCik : string ;
filerName : string | null ;
form : string ;
prevShares : number ;
currShares : number ;
delta : number ;
classification : string ;
reportedQuarter : string ;
} > = [];
for ( const [ cik , cikRows ] of byCik ) {
// Sort by filed_at descending
const sorted = [... cikRows ]. sort (( a , b ) => b . filed_at . localeCompare ( a . filed_at ));
if ( sorted . length < 2 ) continue ;
const curr = sorted [ 0 ];
const prev = sorted [ 1 ];
const prevShares = prev . shares ?? 0 ;
const currShares = curr . shares ?? 0 ;
const delta = currShares - prevShares ;
if ( delta === 0 ) continue ;
let classification : string ;
if ( prevShares === 0 && currShares > 0 ) classification = 'new position' ;
else if ( prevShares > 0 && currShares === 0 ) classification = 'exited' ;
else if ( delta > 0 ) classification = 'added to position' ;
else classification = 'reduced position' ;
allFlow . push ({
filerCik : cik ,
filerName : curr.filer_name ,
form : curr.form ,
prevShares ,
currShares ,
delta ,
classification ,
reportedQuarter : curr.reported_quarter ,
});
}
allFlow . sort (( a , b ) => Math . abs ( b . delta ) - Math . abs ( a . delta ));
// Collect unique quarters
const quarters = [... new Set ( rows . map ( r => r . reported_quarter ))]. sort (). reverse ();
return { symbol , flow : allFlow , quarters };
}),
/** 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 ( 100 ). optional (). default ( 50 ),
}))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
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 = ?
ORDER BY tx_date DESC
LIMIT ?
` ). all ( symbol , 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 {
insiderName : tx.insider_name ,
role : tx.insider_role ,
txDate : tx.tx_date ,
txCode : tx.tx_code ,
txType : tx.tx_type ,
shares ,
price : tx.price ,
is10b5 : tx.is_10b5_1 === 1 ,
classification : tx.classification ,
filedAt : tx.filed_at ,
};
});
return {
symbol ,
events ,
netShares ,
count : events.length ,
};
}),
});
// ─── EDGAR / SEC Filings Router (Slice 6 / M6) ────────────────────────────
interface EdgarFiling {
form : string ;
dateReporter : string ;
accessionNumber : string ;
accessionNormalization : string ;
reportDate : string ;
reportFile : string ;
primaryDocument : string ;
}
interface FilerMeta {
cik : string ;
name : string | null ;
sic : string | null ;
}
interface Form13fHoldingsResult {
holdings : Array < {
cusip : string ;
issuerName : string ;
value : number ;
sshPrnamt : number ;
} > ;
accession : string ;
}
interface Form4TxResult {
transactions : Array < {
reporter : string ;
relationship : string ;
securityTitle : string ;
transactionDate : string ;
transactionCode : string ;
shares : number ;
price : number ;
} > ;
accession : string ;
}
const edgarRouter = router ({
/** Recent filings for a CIK, filterable by form type + date range. */
filings_index : publicProcedure
. input ( z . object ({
cik : z.string (). min ( 1 ),
formTypes : z.array ( z . string ()). optional (),
dateRange : z.object ({ from : z . string (). optional (), to : z.string (). optional () }). optional (),
}))
. query ( async ({ ctx , input }) => {
const edgar = new EdgarAdapter ();
try {
const result = await edgar . filings_index ( input . cik , {
formTypes : input.formTypes ,
dateRange : input.dateRange ,
});
return { filings : result.value as EdgarFiling [], cik : input.cik };
} catch ( e ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : ( e as Error ). message });
}
}),
/** Company facts (XBRL-derived financials) for a CIK. */
company_facts : publicProcedure
. input ( z . object ({ cik : z.string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const edgar = new EdgarAdapter ();
try {
const result = await edgar . company_facts ( input . cik );
return { facts : result.value as Record < string , unknown >, cik : input.cik };
} catch ( e ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : ( e as Error ). message });
}
}),
/** Filer CIK/SIC metadata (name + SIC). */
filer_cik_meta : publicProcedure
. input ( z . object ({ cik : z.string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const edgar = new EdgarAdapter ();
try {
const result = await edgar . filer_cik_meta ( input . cik );
return { meta : result.value as FilerMeta , cik : input.cik };
} catch ( e ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : ( e as Error ). message });
}
}),
/** Full-text search across EDGAR filings. */
full_text_search : publicProcedure
. input ( z . object ({ q : z.string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const edgar = new EdgarAdapter ();
try {
const result = await edgar . full_text_search ( input . q );
return { filings : result.value as Array < Record < string , unknown >> };
} catch ( e ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : ( e as Error ). message });
}
}),
/** 13F-HR holdings for a CIK + accession. */
form13f_holdings : publicProcedure
. input ( z . object ({ cik : z.string (). min ( 1 ), accession : z.string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const edgar = new EdgarAdapter ();
try {
const result = await edgar . form13f_holdings ( input . cik , input . accession );
return { holdings : result.value as Form13fHoldingsResult };
} catch ( e ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : ( e as Error ). message });
}
}),
/** Form 4 insider transactions for a CIK + accession. */
form4_tx : publicProcedure
. input ( z . object ({ cik : z.string (). min ( 1 ), accession : z.string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const edgar = new EdgarAdapter ();
try {
const result = await edgar . form4_tx ( input . cik , input . accession );
return { transactions : result.value as Form4TxResult };
} catch ( e ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : ( e as Error ). message });
}
}),
});
// ─── Watchlist Router (Slice 10) ─────────────────────────────────────────────
const watchlistRouter = router ({
/** List all symbols in the user's default watchlist. */
list : publicProcedure
. query ( async ({ ctx }) => {
const userId = ctx . userId ?? 'anonymous' ;
const { listSymbols } = await import ( '../db/watchlistRepository.ts' );
return listSymbols ( ctx . db , userId );
}),
/** Add a symbol to the default watchlist. */
addSymbol : publicProcedure
. input ( z . object ({ symbol : z . string (). toUpperCase () }))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId ?? 'anonymous' ;
const { addSymbol } = await import ( '../db/watchlistRepository.ts' );
const added = addSymbol ( ctx . db , userId , input . symbol );
// Fire-and-forget: fetch SEC data (13F + Form 4) for this symbol in background.
// Don't await — let the response return immediately.
if ( added ) {
import ( '../services/secDataFetcher.ts' ). then (({ fetchAndStoreSecData }) => {
fetchAndStoreSecData ( ctx . db , input . symbol ). catch (( e ) => {
console . error ( `[sec fetch] failed for ${ input . symbol } :` , e );
});
}). catch (() => { /* sec fetcher not available */ });
}
return { added };
}),
/** Remove a symbol from the default watchlist. */
removeSymbol : publicProcedure
. input ( z . object ({ symbol : z . string (). toUpperCase () }))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId ?? 'anonymous' ;
const { removeSymbol } = await import ( '../db/watchlistRepository.ts' );
const removed = removeSymbol ( ctx . db , userId , input . symbol );
return { removed };
}),
});
// ─── Portfolio Router (Slice 10) ──────────────────────────────────────────────
const portfolioRouter = router ({
/** List all open holdings. */
holdings : publicProcedure
. query ( async ({ ctx }) => {
const userId = ctx . userId ?? 'anonymous' ;
const { listHoldings } = await import ( '../db/portfolioRepository.ts' );
return listHoldings ( ctx . db , userId );
}),
/** Add or accumulate a holding. */
addHolding : protectedProcedure
. input ( z . object ({ symbol : z . string (). toUpperCase (), shares : z.number (). positive (), avgCost : z.number (). min ( 0 ) }))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId as string ;
const { addHolding } = await import ( '../db/portfolioRepository.ts' );
const created = addHolding ( ctx . db , userId , input . symbol , input . shares , input . avgCost );
return { created };
}),
/** Remove (close) a holding. */
removeHolding : protectedProcedure
. input ( z . object ({ symbol : z . string (). toUpperCase () }))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId as string ;
const { removeHolding } = await import ( '../db/portfolioRepository.ts' );
const removed = removeHolding ( ctx . db , userId , input . symbol );
return { removed };
}),
});
// ─── Options Router (Slice 15 / M3) ────────────────────────────────────────
const optionsRouter = router ({
/** Full options chain for a symbol + optional expiry. */
chain : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ), expiry : z.string (). optional () }))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
const adapter = new OptionsAdapter ();
// If no expiry specified, fetch expiry dates and pick nearest
let expiry : string | undefined ;
if ( input . expiry ) {
expiry = input . expiry ;
} else {
try {
const dates = await adapter . expiryDates ( symbol );
if ( dates . length === 0 ) {
return { symbol , expiration : null , rows : [] as OptionChainRow [] };
}
// Pick the nearest expiry date
expiry = dates . sort ()[ 0 ];
} catch {
return { symbol , expiration : null , rows : [] as OptionChainRow [] };
}
}
try {
const chain = await adapter . chain ( symbol , expiry );
return { symbol , expiration : chain.expiration , rows : chain.rows };
} catch {
return { symbol , expiration : expiry ?? null , rows : [] as OptionChainRow [] };
}
}),
/** Greeks for a specific option (strike + expiry). */
greeks : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ), expiry : z.string (). optional (), strike : z.number (). optional () }))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
const adapter = new OptionsAdapter ();
// If no expiry specified, fetch first available
let expiry : string | undefined ;
if ( input . expiry ) {
expiry = input . expiry ;
} else {
try {
const dates = await adapter . expiryDates ( symbol );
if ( dates . length === 0 ) {
return { symbol , greeks : null as OptionGreeks | null };
}
expiry = dates . sort ()[ 0 ];
} catch {
return { symbol , greeks : null as OptionGreeks | null };
}
}
try {
const key = `yfinance:greeks: ${ symbol } : ${ expiry } : ${ input . strike ?? 0 } ` ;
const result = await adapter . fetchOne ( key );
const row = result . value as OptionChainRow | null ;
return {
symbol ,
greeks : row?.greeks ?? null ,
strike : row?.strike ?? input . strike ?? null ,
right : row?.right ?? null ,
lastPrice : row?.lastPrice ?? null ,
impliedVolatility : row?.impliedVolatility ?? null ,
};
} catch {
return { symbol , greeks : null as OptionGreeks | null };
}
}),
});
const reportsRouter = router ({
/** Generate a research note report. */
generate : protectedProcedure
. input ( z . object ({
scope : z.enum ([ 'symbol' , 'watchlist' , 'portfolio' , 'rotation' , 'sizing_year' , 'risk_posture' ]),
symbol : z . string (). optional (),
data : z.record ( z . unknown ()). optional (). default ({}),
}))
. query ( async ({ ctx , input }) => {
const { generateReport } = await import ( '../reports/ReportRunner.ts' );
return generateReport ({
scope : input.scope ,
symbol : input . symbol ,
data : input.data as Record < string , unknown >,
});
}),
});
// ─── Screener Router (Slice 13) ─────────────────────────────────────────────
const screenerRouter = router ({
/** Filter screener — evaluate a filter expression over a universe. */
filter : protectedProcedure
. input ( z . object ({
expression : z.string (). min ( 1 ),
scope : z.enum ([ 'watchlist' , 'sector' ]). default ( 'watchlist' ),
sector : z.string (). optional (),
}))
. query ( async ({ ctx , input }) => {
const { filterUniverse , scopeUniverse } = await import ( '../screener/UniverseEvaluator.ts' );
// Build universe from cache (simplified — uses watchlist symbols)
const userId = ctx . userId as string ;
const wlRow = ctx . db . prepare ( 'SELECT symbols FROM watchlists WHERE owner_id=? ORDER BY sort_order LIMIT 1' ). get ( userId ) as { symbols : string } | undefined ;
const symbols : string [] = wlRow ? JSON . parse ( wlRow . symbols ) : [];
const universe : SymbolUniverseData [] = [];
for ( const sym of symbols ) {
const entry = await ctx . cache . get < unknown >( `yfinance:quote: ${ sym } ` );
const quote = entry . value as { regularMarketPrice? : number ; regularMarketVolume? : number ; averageVolume? : number } | null ;
universe . push ({
symbol : sym ,
price : quote?.regularMarketPrice ,
volume : quote?.regularMarketVolume ,
avgVolume : quote?.averageVolume ,
});
}
return { results : filterUniverse ( input . expression , universe ), scope : input.scope };
}),
/** Strategy screener — screen universe against strategy entry conditions. */
strategy : protectedProcedure
. input ( z . object ({ strategyId : z.string () }))
. query ( async ({ ctx , input }) => {
const { screenByStrategy } = await import ( '../screener/UniverseEvaluator.ts' );
const strat = ctx . db . prepare ( 'SELECT * FROM strategies WHERE id=? AND owner_id=?' ). get ( input . strategyId , ctx . userId ) as { components : string } | undefined ;
if ( ! strat ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Strategy not found.' });
const components = JSON . parse ( strat . components ) as Array < { type : string ; conditions? : string [] } > ;
const setup = components . find (( c ) => c . type === 'setup' );
if ( ! setup ? . conditions ) return { results : [] };
const userId = ctx . userId as string ;
const wlRow = ctx . db . prepare ( 'SELECT symbols FROM watchlists WHERE owner_id=? ORDER BY sort_order LIMIT 1' ). get ( userId ) as { symbols : string } | undefined ;
const symbols : string [] = wlRow ? JSON . parse ( wlRow . symbols ) : [];
const universe : SymbolUniverseData [] = [];
for ( const sym of symbols ) {
const entry = await ctx . cache . get < unknown >( `yfinance:quote: ${ sym } ` );
const quote = entry . value as { regularMarketPrice? : number ; regularMarketVolume? : number ; averageVolume? : number } | null ;
universe . push ({ symbol : sym , price : quote?.regularMarketPrice , volume : quote?.regularMarketVolume , avgVolume : quote?.averageVolume });
}
return { results : screenByStrategy ( setup . conditions , universe ) };
}),
});
// ─── Strategy + Backtest Router (Slice 12) ──────────────────────────────────
const strategyRouter = router ({
list : protectedProcedure.query ( async ({ ctx }) => {
const userId = ctx . userId as string ;
return ctx . db . prepare ( 'SELECT id, name, components, unlocked, created_at FROM strategies WHERE owner_id=? ORDER BY created_at DESC' ). all ( userId );
}),
create : protectedProcedure
. input ( z . object ({ name : z.string (). min ( 1 ), components : z.array ( z . unknown ()) }))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId as string ;
const id = randomUUID ();
ctx . db . prepare ( 'INSERT INTO strategies (id, owner_id, name, components, unlocked, created_at) VALUES (?,?,?,?,?,?)' ). run ( id , userId , input . name , JSON . stringify ( input . components ), 0 , new Date (). toISOString ());
return { id };
}),
});
const backtestRouter = router ({
run : protectedProcedure
. input ( z . object ({ strategyId : z.string (), symbol : z . string (), timeframe : z.enum ([ '1d' , '1wk' ]). default ( '1d' ) }))
. query ( async ({ ctx , input }) => {
const { BacktestEngine } = await import ( '../strategy/BacktestEngine.ts' );
const strat = ctx . db . prepare ( 'SELECT * FROM strategies WHERE id=? AND owner_id=?' ). get ( input . strategyId , ctx . userId ) as { id : string ; owner_id : string ; name : string ; components : string ; unlocked : number ; created_at : string } | undefined ;
if ( ! strat ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Strategy not found.' });
const strategy = {
id : strat.id , ownerId : strat.owner_id , name : strat.name ,
components : JSON.parse ( strat . components ), unlocked : !! strat . unlocked , createdAt : strat.created_at ,
};
const candleEntry = await ctx . cache . get < unknown [] >( `yfinance:candles: ${ input . symbol } : ${ input . timeframe } ` );
const candles = candleEntry . value ?? [];
const engine = new BacktestEngine ();
return engine . run ( strategy , input . symbol . toUpperCase (), candles as any [], input . timeframe );
}),
evaluateLatest : protectedProcedure
. input ( z . object ({ strategyId : z.string (), symbol : z . string () }))
. query ( async ({ ctx , input }) => {
const { BacktestEngine } = await import ( '../strategy/BacktestEngine.ts' );
const strat = ctx . db . prepare ( 'SELECT * FROM strategies WHERE id=? AND owner_id=?' ). get ( input . strategyId , ctx . userId ) as { id : string ; owner_id : string ; name : string ; components : string ; unlocked : number ; created_at : string } | undefined ;
if ( ! strat ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Strategy not found.' });
const strategy = {
id : strat.id , ownerId : strat.owner_id , name : strat.name ,
components : JSON.parse ( strat . components ), unlocked : !! strat . unlocked , createdAt : strat.created_at ,
};
const candleEntry = await ctx . cache . get < unknown [] >( `yfinance:candles: ${ input . symbol } :1d` );
const candles = candleEntry . value ?? [];
const engine = new BacktestEngine ();
return engine . evaluateLatest ( strategy , candles as any []);
}),
});
// ─── Sector Crosslink Router (Slice 14) ─────────────────────────────────────
const sectorCrosslinkRouter = router ({
confirm : protectedProcedure
. input ( z . object ({ strategyId : z.string (), sector : z.string () }))
. query ( async ({ ctx , input }) => {
const { crossLinkSector } = await import ( '../screener/SectorCrosslink.ts' );
const strat = ctx . db . prepare ( 'SELECT * FROM strategies WHERE id=? AND owner_id=?' ). get ( input . strategyId , ctx . userId ) as { components : string } | undefined ;
if ( ! strat ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Strategy not found.' });
const components = JSON . parse ( strat . components ) as Array < { type : string ; conditions? : string [] } > ;
const setup = components . find (( c ) => c . type === 'setup' );
const conditions = setup ? . conditions ?? [];
// Get rotation signals from DB if available
const signals = ctx . db . prepare ( 'SELECT * FROM rotation_signals ORDER BY ts DESC LIMIT 10' ). all () as Array < Record < string , unknown >>;
const rotationSignals = signals . map (( s ) => ({
fromSector : String ( s . from_sector ?? '' ),
toSector : String ( s . to_sector ?? s . sector ?? '' ),
confidence : Number ( s . confidence ?? 50 ),
date : String ( s . ts ?? '' ),
}));
const universe : any [] = [];
return crossLinkSector ( input . strategyId , input . sector , conditions , universe , rotationSignals );
}),
});
// ─── Options Convexity Router (Slice 19) ────────────────────────────────────
const optionsConvexityRouter = router ({
unlock : protectedProcedure
. input ( z . object ({ fromState : z.number (). int (). min ( 0 ). max ( 4 ), toState : z.number (). int (). min ( 0 ). max ( 4 ) }))
. mutation ( async ({ ctx , input }) => {
const { canElevate } = await import ( '../options/ConvexityGate.ts' );
const userId = ctx . userId as string ;
const row = ctx . db . prepare ( 'SELECT state, understanding FROM options_unlock WHERE user_id=?' ). get ( userId ) as { state : number ; understanding : number } | undefined ;
const currentState = ( row ? . state ?? 0 );
const hasUnderstanding = ( row ? . understanding ?? 0 ) === 1 ;
const result = canElevate ( currentState , input . toState , hasUnderstanding , true , input . toState >= 3 , input . toState === 4 );
if ( ! result . allowed ) throw new TRPCError ({ code : 'BAD_REQUEST' , message : result.reason });
ctx . db . prepare ( 'INSERT OR REPLACE INTO options_unlock (user_id, state, last_unlock_at, understanding) VALUES (?,?,?,?)' ). run ( userId , input . toState , new Date (). toISOString (), row ? . understanding ?? 0 );
return { state : input.toState , reason : result.reason };
}),
getPayoff : protectedProcedure
. input ( z . object ({ strike : z.number (), premium : z.number (), right : z.enum ([ 'call' , 'put' ]), underlyingPrice : z.number () }))
. query ( async ({ ctx , input }) => {
const { computePayoffDiagram } = await import ( '../options/ConvexityGate.ts' );
return computePayoffDiagram ( input . strike , input . premium , input . right , input . underlyingPrice );
}),
});
// ─── Derisking Router (Slice 23) ────────────────────────────────────────────
const deriskingRouter = router ({
suggest : protectedProcedure
. input ( z . object ({
symbol : z . string (),
currentPrice : z.number (),
avgCost : z.number (),
shares : z.number (),
ema21 : z.number (). optional (),
ema50 : z.number (). optional (),
thesisStatus : z.enum ([ 'intact' , 'weakening' , 'broken' ]). optional (),
currentRegime : z.enum ([ 'trending-up' , 'trending-down' , 'range-bound' ]). optional (),
portfolioCorrelation : z.number (). optional (),
optionsUnlockState : z.number (). optional (),
profitTargets : z.array ( z . number ()). optional (),
}))
. query ( async ({ ctx , input }) => {
const { suggestDerisking } = await import ( '../derisking/DeriskingEngine.ts' );
return { suggestions : suggestDerisking ( input ) };
}),
});
// ─── Macro Router (Slice 20) ────────────────────────────────────────────────
const macroRouter = router ({
series : protectedProcedure
. input ( z . object ({ seriesId : z.string () }))
. query ( async ({ ctx , input }) => {
const { FredAdapterImpl } = await import ( '../macro/FredAdapter.ts' );
const adapter = new FredAdapterImpl ();
return adapter . series ( input . seriesId );
}),
calendar : protectedProcedure.query ( async ({ ctx }) => {
// Return cached economic calendar events
const entry = await ctx . cache . get < unknown [] >( 'macro:calendar' );
return { events : entry.value ?? [], isStale : entry.isStale };
}),
regimeClassify : protectedProcedure.query ( async ({ ctx }) => {
const { classifyRegime } = await import ( '../macro/MacroRegime.ts' );
// Gather macro inputs from cache
const input : Record < string , number > = {};
const gdpEntry = await ctx . cache . get < unknown >( 'fred:series:GDP' );
if ( gdpEntry . value ) { const obs = ( gdpEntry . value as any ). observations ; if ( obs ? . length ) input . gdpGrowth = obs [ obs . length - 1 ]. value ; }
const cpiEntry = await ctx . cache . get < unknown >( 'fred:series:CPIAUCSL' );
if ( cpiEntry . value ) { const obs = ( cpiEntry . value as any ). observations ; if ( obs ? . length ) input . cpi = obs [ obs . length - 1 ]. value ; }
return classifyRegime ( input );
}),
commentary : protectedProcedure.query ( async ({ ctx }) => {
const { classifyRegime , generateMacroCommentary } = await import ( '../macro/MacroRegime.ts' );
const input : Record < string , number > = {};
const gdpEntry = await ctx . cache . get < unknown >( 'fred:series:GDP' );
if ( gdpEntry . value ) { const obs = ( gdpEntry . value as any ). observations ; if ( obs ? . length ) input . gdpGrowth = obs [ obs . length - 1 ]. value ; }
const classification = classifyRegime ( input );
return generateMacroCommentary ( classification );
}),
regimeHistory : protectedProcedure.query ( async ({ ctx }) => {
return ctx . db . prepare ( 'SELECT * FROM regime_history ORDER BY date DESC LIMIT 50' ). all ();
}),
});
// ─── Thesis Monitor Router (Slice 21) ───────────────────────────────────────
const thesisMonitorRouter = router ({
assess : protectedProcedure
. input ( z . object ({ symbol : z . string () }))
. query ( async ({ ctx , input }) => {
const { assessThesis } = await import ( '../thesis/ThesisMonitor.ts' );
const userId = ctx . userId as string ;
const thesis = ctx . db . prepare ( 'SELECT * FROM theses WHERE user_id=? AND symbol=? ORDER BY updated_at DESC LIMIT 1' ). get ( userId , input . symbol . toUpperCase ()) as { statement : string ; invalidation_criteria : string } | undefined ;
if ( ! thesis ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'No thesis found for this symbol.' });
const events : ThesisEvent [] = [];
// Gather events from DB (form4, 13f, etc.)
return assessThesis ({
symbol : input . symbol . toUpperCase (),
statement : thesis.statement ,
invalidationCriteria : JSON.parse ( thesis . invalidation_criteria ) as string [],
createdAt : new Date (). toISOString (),
}, events );
}),
timeline : protectedProcedure.query ( async ({ ctx }) => {
const { buildTimeline } = await import ( '../thesis/ThesisMonitor.ts' );
const userId = ctx . userId as string ;
const theses = ctx . db . prepare ( 'SELECT * FROM theses WHERE user_id=?' ). all ( userId ) as Array < Record < string , unknown >>;
// For each thesis, get assessment (simplified)
return { timeline : [], theses };
}),
});
// ─── X/Cookie + Reddit Router (Slice 16) ────────────────────────────────────
const xRouter = router ({
cashtag_search : protectedProcedure
. input ( z . object ({ cashtag : z.string (), limit : z.number (). min ( 1 ). max ( 100 ). optional (). default ( 20 ) }))
. query ( async ({ ctx , input }) => {
const { XCookieAdapter } = await import ( '../adapters/XCookieAdapter.ts' );
const adapter = new XCookieAdapter ();
const key = `x:cashtag: ${ input . cashtag } : ${ input . limit } ` ;
const entry = await ctx . cache . get < unknown >( key );
if ( entry . value ) return { posts : entry.value , isStale : entry.isStale };
try {
const result = await adapter . cashtag_search ( input . cashtag , input . limit );
return { posts : result.value , isStale : false };
} catch ( e ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : ( e as Error ). message });
}
}),
timeline : protectedProcedure
. input ( z . object ({ account : z.string (), limit : z.number (). min ( 1 ). max ( 100 ). optional (). default ( 20 ) }))
. query ( async ({ ctx , input }) => {
const { XCookieAdapter } = await import ( '../adapters/XCookieAdapter.ts' );
const adapter = new XCookieAdapter ();
try {
const result = await adapter . trusted_account_timeline ( input . account , input . limit );
return { posts : result.value , isStale : false };
} catch ( e ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : ( e as Error ). message });
}
}),
});
const redditRouter = router ({
subreddit : protectedProcedure
. input ( z . object ({ subreddit : z.string (), limit : z.number (). min ( 1 ). max ( 100 ). optional (). default ( 25 ) }))
. query ( async ({ ctx , input }) => {
const { RedditAdapter } = await import ( '../adapters/RedditAdapter.ts' );
const adapter = new RedditAdapter ();
try {
const result = await adapter . subreddit ( input . subreddit , input . limit );
return { posts : result.value , isStale : false };
} catch ( e ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : ( e as Error ). message });
}
}),
search : protectedProcedure
. input ( z . object ({ q : z.string (), limit : z.number (). min ( 1 ). max ( 100 ). optional (). default ( 25 ) }))
. query ( async ({ ctx , input }) => {
const { RedditAdapter } = await import ( '../adapters/RedditAdapter.ts' );
const adapter = new RedditAdapter ();
try {
const result = await adapter . search ( input . q , input . limit );
return { posts : result.value , isStale : false };
} catch ( e ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : ( e as Error ). message });
}
}),
});
// ---------------------------------------------------------------------------
// Emotion Logger Router (Slice: emotion-logger-storage)
// ---------------------------------------------------------------------------
const emotionLoggerRouter = router ({
/** Add an emotion log entry for a trade execution. */
add : protectedProcedure
. input ( z . object ({
tradeExecutionId : z.string (),
priceAtEvent : z.number (). optional (),
emotion : z.string (),
note : z.string (). optional (),
}))
. mutation ( async ({ ctx , input }) => {
const { addEmotionLog } = await import ( '../db/emotionLogRepository.ts' );
try {
const log = addEmotionLog ( ctx . db , {
tradeExecutionId : input.tradeExecutionId ,
priceAtEvent : input.priceAtEvent ,
emotion : input.emotion ,
note : input.note ,
});
return { success : true , log };
} catch ( e ) {
throw new TRPCError ({ code : 'INTERNAL_SERVER_ERROR' , message : ( e as Error ). message });
}
}),
/** Get all emotion logs for a trade execution. */
getByTrade : protectedProcedure
. input ( z . object ({ tradeExecutionId : z.string () }))
. query ( async ({ ctx , input }) => {
const { getEmotionLogsByTrade } = await import ( '../db/emotionLogRepository.ts' );
try {
const logs = getEmotionLogsByTrade ( ctx . db , input . tradeExecutionId );
return { logs };
} catch ( e ) {
throw new TRPCError ({ code : 'INTERNAL_SERVER_ERROR' , message : ( e as Error ). message });
}
}),
/** Delete an emotion log by id. */
delete : protectedProcedure
. input ( z . object ({ id : z.string () }))
. mutation ( async ({ ctx , input }) => {
const { deleteEmotionLog } = await import ( '../db/emotionLogRepository.ts' );
try {
deleteEmotionLog ( ctx . db , input . id );
return { success : true };
} catch ( e ) {
throw new TRPCError ({ code : 'INTERNAL_SERVER_ERROR' , message : ( e as Error ). message });
}
}),
});
export const appRouter = router ({
auth : authRouter , onboarding : onboardingRouter , market : marketRouter , dashboard : dashboardRouter ,
admin : adminRouter , alerts : alertsRouter , edgar : edgarRouter , institutional : institutionalRouter ,
watchlists : watchlistRouter , portfolio : portfolioRouter , options : optionsRouter , reports : reportsRouter ,
screener : screenerRouter , strategies : strategyRouter , backtest : backtestRouter ,
sectorCrosslink : sectorCrosslinkRouter , optionsConvexity : optionsConvexityRouter ,
derisking : deriskingRouter , macro : macroRouter , thesisMonitor : thesisMonitorRouter ,
x : xRouter , reddit : redditRouter , emotionLogger : emotionLoggerRouter ,
});
2026-06-29 17:40:42 -04:00
export type AppRouter = typeof appRouter ;
2026-07-05 08:50:12 -04:00