2026-06-29 17:40:42 -04:00
import { initTRPC , TRPCError } from '@trpc/server' ;
import { z } from 'zod' ;
2026-07-23 18:02:24 -04:00
import type { SQLInputValue } from 'node:sqlite' ;
2026-06-29 17:40:42 -04:00
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-07-23 18:02:24 -04:00
import type { SymbolUniverseData } from '../screener/UniverseEvaluator.ts' ;
import type { ThesisEvent } from '../thesis/ThesisMonitor.ts' ;
import type { OptionsUnlockState } from '../options/ConvexityGate.ts' ;
import type { XCookieHealth } from '../adapters/XCookieAdapter.ts' ;
import { emaFromCandles , rsi as rsiFn , relativeVolume , macd as macdFn } from '../analysis/indicators.ts' ;
2026-07-25 11:38:23 -04:00
import { listUsers , resetPassword , gdprExport , queueHealth , resetQueueBackoff , NotOwnerError , listUserSessions , listAuditLog , queueSecFetch , setUserModules , disableUser , enableUser , deleteUser } from '../admin/admin.ts' ;
2026-07-23 18:02:24 -04:00
import { restartServers , type RestartTarget } from '../lib/restart.ts' ;
2026-07-12 20:19:12 -04:00
import type { LintResult } from '../services/secDataFetcher.ts' ;
2026-07-05 08:50:12 -04:00
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
2026-07-23 18:02:24 -04:00
// ---------------------------------------------------------------------------
// 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 */ }
}
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.' });
2026-07-23 18:02:24 -04:00
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.' });
2026-06-29 19:32:09 -04:00
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 ();
2026-07-23 18:02:24 -04:00
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.' };
2026-06-29 17:40:42 -04:00
}),
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-07-23 18:02:24 -04:00
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 ;
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-07-23 18:02:24 -04:00
if ( row . status !== 'active' ) throw new TRPCError ({ code : 'FORBIDDEN' , message : row.status === 'pending_approval' ? 'Account pending admin approval.' : 'Account disabled.' });
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 ;
2026-07-25 11:38:23 -04:00
const u = ctx . db . prepare ( 'SELECT id,email,complexity,risk_tolerance,convexity_posture,modules FROM users WHERE id=?' ). get ( ctx . userId ) as { id : string ; email : string ; complexity : string ; risk_tolerance : string ; convexity_posture : string ; modules : string } | undefined ;
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 );
2026-07-25 11:38:23 -04:00
let modules : string [] = [ 'research' , 'settings' ];
try { modules = JSON . parse ( u ? . modules ?? '["research","settings"]' ); } catch { /* keep default */ }
if ( u ? . id ) {
const adminRow = ctx . db . prepare ( 'SELECT is_admin FROM users WHERE id=?' ). get ( u . id ) as { is_admin : number } | undefined ;
if ( adminRow ? . is_admin && ! modules . includes ( 'admin' )) modules . push ( 'admin' );
}
return u ? { userId : u.id , email : u.email , complexity : u.complexity , riskTolerance : u.risk_tolerance , convexityPosture : u.convexity_posture , onboarded : !! wl , modules } : null ;
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 ();
2026-07-23 20:51:47 -04:00
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 );
2026-06-29 21:31:59 -04:00
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 );
2026-07-12 12:55:59 -04:00
queueSecFetch ( ctx . db , sym );
2026-06-29 21:31:59 -04:00
}
2026-07-25 11:38:23 -04:00
try {
const { materializeClassificationWatchlists } = await import ( '../db/watchlistRepository.ts' );
materializeClassificationWatchlists ( ctx . db , userId );
} catch { /* ignore — sector data not available yet, will materialize on first listWatchlists */ }
2026-06-29 21:31:59 -04:00
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-07-23 18:02:24 -04:00
/**
* Focused-ticker context: performance vs market, sector/theme proxies, and peers.
* Educational relative snapshot (ADR-0007).
*/
tickerContext : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const {
returnsBundle ,
buildTickerContext ,
relativeTo ,
resolveBusinessContext ,
} = await import ( '../analysis/tickerContext.ts' );
const {
MARKET_ROTATION_UNIVERSE ,
BENCHMARK_SYMBOL ,
buildSectorRsMap ,
} = await import ( '../analysis/marketRotationRs.ts' );
const { classifyRegime } = await import ( '../macro/MacroRegime.ts' );
const symbol = input . symbol . toUpperCase ();
try { await ctx . cache . subscribe ( symbol , 'equity' ); } catch { /* ignore */ }
try { await ctx . cache . subscribe ( BENCHMARK_SYMBOL , 'etf' ); } catch { /* ignore */ }
const metaEntry = await ctx . cache . get < SymbolMeta >( `yfinance:symbol: ${ symbol } ` );
const meta = metaEntry . value ;
const name = meta ? . name ?? null ;
const sectorName = meta ? . sector ?? null ;
const industry = meta ? . industry ?? null ;
const tickerKind = meta ? . tickerKind ?? 'equity' ;
const description = meta ? . description ?? null ;
// Prefer operating-profile resolution over raw Yahoo GICS (fixes IREN→XLF etc.).
const biz = resolveBusinessContext ({
symbol ,
sector : sectorName ,
industry ,
description ,
});
const sectorMap = biz . sectorEtf ? { etf : biz.sectorEtf , label : biz.sectorLabel ?? biz . sectorEtf } : null ;
const themeMap = biz . themeEtf ? { etf : biz.themeEtf , label : biz.themeLabel ?? biz . themeEtf } : null ;
// Peers: curated override first, then vendor peers, then sector-ETF holdings (never when blocked).
let peerSymbols : string [] = biz . peers . length > 0
? biz . peers . filter (( p ) => p !== symbol )
: Array . isArray ( meta ? . peers )
? ( meta ! . peers as string []). map (( p ) => String ( p ). toUpperCase ()). filter (( p ) => p && p !== symbol )
: [];
if ( peerSymbols . length === 0 && sectorMap && ! biz . blockSectorEtfPeers ) {
try {
const mod = await import ( 'yahoo-finance2' );
const yf = new mod . default ();
const summary = await yf . quoteSummary ( sectorMap . etf , { modules : [ 'topHoldings' ] }) as {
topHoldings ?: { holdings? : Array < { symbol ?: string } > };
};
peerSymbols = ( summary ? . topHoldings ? . holdings ?? [])
. map (( h ) => ( h . symbol ?? '' ). toUpperCase ())
. filter (( p ) => p && p !== symbol )
. slice ( 0 , 8 );
} catch { /* live peer pull optional */ }
}
const symbolsToLoad = Array . from ( new Set ([
symbol ,
BENCHMARK_SYMBOL ,
...( sectorMap ? [ sectorMap . etf ] : []),
...( themeMap ? [ themeMap . etf ] : []),
... peerSymbols . slice ( 0 , 8 ),
]));
for ( const s of symbolsToLoad ) {
try { await ctx . cache . subscribe ( s , s === BENCHMARK_SYMBOL || s . length <= 4 && MARKET_ROTATION_UNIVERSE . some (( u ) => u . symbol === s ) ? 'etf' : 'equity' ); } catch { /* ignore */ }
}
const candleKeys = symbolsToLoad . map (( s ) => `yfinance:candles: ${ s } :1d` );
const candleEntries = await ctx . cache . getMany < PriceCandle [] >( candleKeys );
const candlesBySym = new Map < string , PriceCandle [] >();
symbolsToLoad . forEach (( s , i ) => {
candlesBySym . set ( s , ( candleEntries [ i ] ? . value ?? []) as PriceCandle []);
});
const toPts = ( arr : PriceCandle []) => arr . map (( c ) => ({ ts : c.ts , c : c.c , v : c.v }));
const symbolReturns = returnsBundle ( toPts ( candlesBySym . get ( symbol ) ?? []));
const marketReturns = returnsBundle ( toPts ( candlesBySym . get ( BENCHMARK_SYMBOL ) ?? []));
const sectorReturns = sectorMap
? returnsBundle ( toPts ( candlesBySym . get ( sectorMap . etf ) ?? []))
: null ;
const themeReturns = themeMap
? returnsBundle ( toPts ( candlesBySym . get ( themeMap . etf ) ?? []))
: null ;
// Sector leadership from RS map when possible.
let sectorLeadership : 'leading' | 'lagging' | 'inline' | 'unknown' | null = null ;
try {
const rotSymbols = [ BENCHMARK_SYMBOL , ... MARKET_ROTATION_UNIVERSE . map (( u ) => u . symbol )];
const rotKeys = rotSymbols . map (( s ) => `yfinance:candles: ${ s } :1d` );
const rotEntries = await ctx . cache . getMany < PriceCandle [] >( rotKeys );
const map : Record < string , PriceCandle [] > = {};
rotSymbols . forEach (( s , i ) => { map [ s ] = ( rotEntries [ i ] ? . value ?? []) as PriceCandle []; });
const rows = buildSectorRsMap ( MARKET_ROTATION_UNIVERSE , map , map [ BENCHMARK_SYMBOL ] ?? []);
if ( sectorMap ) {
const row = rows . find (( r ) => r . symbol === sectorMap . etf );
sectorLeadership = row ? . leadership ?? null ;
}
} catch { /* optional */ }
// Market regime from available FRED/SPY factors (same soft path as condition).
let marketRegime : 'trending-up' | 'trending-down' | 'range-bound' | null = null ;
let marketRegimeConfidence : number | null = null ;
try {
const regimeInput : Record < string , number > = {};
const spy = candlesBySym . get ( BENCHMARK_SYMBOL ) ?? [];
if ( spy . length >= 50 ) {
const last = spy [ spy . length - 1 ]. c ;
const ago = spy [ spy . length - 50 ]. c ;
if ( ago > 0 ) regimeInput . spyEmaSlope = (( last - ago ) / ago ) * 100 ;
}
try {
const vixEntry = await ctx . cache . get < Quote >( 'yfinance:quote:^VIX' );
if ( vixEntry ? . value ? . price != null ) regimeInput . vix = vixEntry . value . price ;
} catch { /* ignore */ }
const classification = classifyRegime ( regimeInput );
marketRegime = classification . regime ;
marketRegimeConfidence = classification . confidence ;
} catch { /* ignore */ }
const peers = peerSymbols . slice ( 0 , 8 ). map (( p ) => {
const ret = returnsBundle ( toPts ( candlesBySym . get ( p ) ?? []));
return {
symbol : p ,
name : null as string | null ,
returns : ret ,
rsVsMarket1M : relativeTo ( ret . oneMonth , marketReturns . oneMonth ),
};
});
// Optional peer names from symbol meta cache.
for ( const p of peers ) {
try {
const pe = await ctx . cache . get < SymbolMeta >( `yfinance:symbol: ${ p . symbol } ` );
if ( pe . value ? . name ) p . name = pe . value . name ;
} catch { /* ignore */ }
}
return buildTickerContext ({
symbol ,
name ,
sector : sectorName ,
industry ,
tickerKind ,
symbolReturns ,
marketReturns ,
marketRegime ,
marketRegimeConfidence ,
sectorEtf : sectorMap?.etf ?? null ,
sectorLabel : sectorMap?.label ?? sectorName ,
sectorReturns ,
sectorLeadership ,
themeEtf : themeMap?.etf ?? null ,
themeLabel : themeMap?.label ?? null ,
themeReturns ,
peers ,
classificationNote : biz.classificationNote ,
vendorSector : biz.vendorSector ,
vendorIndustry : biz.vendorIndustry ,
});
}),
2026-06-29 22:57:34 -04:00
candles : publicProcedure
2026-07-23 18:02:24 -04:00
. input ( z . object ({ symbol : z . string (). min ( 1 ), timeframe : z.enum ([ '1d' , '1wk' , '1mo' ]). default ( '1d' ) }))
2026-06-29 22:57:34 -04:00
. 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 ),
2026-07-23 18:02:24 -04:00
timeframe : z.enum ([ '1d' , '1wk' , '1mo' ]). default ( '1d' ),
2026-06-29 22:57:34 -04:00
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 ),
2026-07-23 18:02:24 -04:00
}). default (() => ({ ema : [ 9 , 21 , 50 , 200 ], rsi : 14 , relvol : 20 })),
2026-06-29 22:57:34 -04:00
}))
. 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 );
}
2026-07-23 18:02:24 -04:00
const macdResult = macdFn ( closes );
2026-06-29 22:57:34 -04:00
return {
ema : emaObj ,
rsi : rsiFn ( closes , periods . rsi ),
2026-07-23 18:02:24 -04:00
relvol : relativeVolume ( volumes , periods . relvol ),
macd : macdResult ,
2026-06-29 22:57:34 -04:00
};
}),
2026-07-23 18:02:24 -04:00
truckSales : publicProcedure.query ( async ({ ctx }) => {
const row = ctx . db . prepare ( 'SELECT fred_api_key_enc FROM x_credentials WHERE id=?' ). get ( 'singleton' ) as { fred_api_key_enc? : string | null } | undefined ;
let apiKey = '' ;
if ( row ? . fred_api_key_enc ) {
const { default : encrypt } = await import ( '../lib/crypto.ts' );
try { apiKey = encrypt . decrypt ( row . fred_api_key_enc ); } catch { /* key corrupt */ }
}
if ( ! apiKey ) {
return { observations : [], seriesTitle : 'Heavy Trucks Sold, Monthly' , units : '' , configured : false , error : 'FRED API key not configured' };
}
try {
const { FredAdapterImpl , FRED_SERIES } = await import ( '../macro/FredAdapter.ts' );
const adapter = new FredAdapterImpl ( apiKey );
const entry = await adapter . series ( FRED_SERIES . HEAVY_TRUCK_SALES );
const series = entry . value as { seriesId : string ; title : string ; units : string ; observations : Array < { date : string ; value : number } > };
return { observations : series.observations , seriesTitle : series.title , units : series.units , configured : true };
} catch ( e ) {
return { observations : [], seriesTitle : 'Heavy Trucks Sold, Monthly' , units : '' , configured : true , error : ( e as Error ). message };
}
}),
manufacturingPmi : publicProcedure.query ( async ({ ctx }) => {
const row = ctx . db . prepare ( 'SELECT fred_api_key_enc FROM x_credentials WHERE id=?' ). get ( 'singleton' ) as { fred_api_key_enc? : string | null } | undefined ;
let apiKey = '' ;
if ( row ? . fred_api_key_enc ) {
const { default : encrypt } = await import ( '../lib/crypto.ts' );
try { apiKey = encrypt . decrypt ( row . fred_api_key_enc ); } catch { /* key corrupt */ }
}
if ( ! apiKey ) {
return { observations : [], seriesTitle : 'Industrial Production: Manufacturing' , units : '' , configured : false , error : 'FRED API key not configured' };
}
try {
const { FredAdapterImpl , FRED_SERIES } = await import ( '../macro/FredAdapter.ts' );
const adapter = new FredAdapterImpl ( apiKey );
const entry = await adapter . series ( FRED_SERIES . MANUFACTURING_ACTIVITY );
const series = entry . value as { seriesId : string ; title : string ; units : string ; observations : Array < { date : string ; value : number } > };
return { observations : series.observations , seriesTitle : series.title , units : series.units , configured : true };
} catch ( e ) {
return { observations : [], seriesTitle : 'Industrial Production: Manufacturing' , units : '' , configured : true , error : ( e as Error ). message };
}
}),
/**
* Market Condition strip — regime + risk flags.
* FRED series are fetched live (key from admin) when not already warm; SPY/VIX from yfinance.
* Educational snapshot only (ADR-0007).
*/
condition : publicProcedure.query ( async ({ ctx }) => {
const { classifyRegime } = await import ( '../macro/MacroRegime.ts' );
const { totalReturnPct } = await import ( '../analysis/marketRotationRs.ts' );
const { FredAdapterImpl , FRED_SERIES } = await import ( '../macro/FredAdapter.ts' );
const factors : Record < string , number | null > = {
gdpGrowth : null ,
cpi : null ,
unemployment : null ,
treasury10Y : null ,
treasury2Y : null ,
curve10y2y : null ,
vix : null ,
spy1M : null ,
spyEmaSlope : null ,
};
// Decrypt FRED key (same store as truck sales / manufacturing).
let fredKey = '' ;
try {
const row = ctx . db . prepare ( 'SELECT fred_api_key_enc FROM x_credentials WHERE id=?' ). get ( 'singleton' ) as { fred_api_key_enc? : string | null } | undefined ;
if ( row ? . fred_api_key_enc ) {
const { default : encrypt } = await import ( '../lib/crypto.ts' );
try { fredKey = encrypt . decrypt ( row . fred_api_key_enc ); } catch { /* corrupt */ }
}
} catch { /* ignore */ }
type FredObs = Array < { date : string ; value : number } > ;
/** FRED observations: kv_cache first, then live API; write-through to kv_cache. */
async function fredObs ( seriesId : string ) : Promise < FredObs | null > {
const cacheKey = `fred:series: ${ seriesId } ` ;
try {
const row = ctx . db . prepare ( 'SELECT value, observed_at FROM kv_cache WHERE key=?' ). get ( cacheKey ) as
| { value : string ; observed_at : string } | undefined ;
if ( row ? . value ) {
const age = Date . now () - Date . parse ( row . observed_at );
const parsed = JSON . parse ( row . value ) as { observations? : FredObs };
const obs = parsed . observations ;
if ( obs ? . length && Number . isFinite ( age ) && age < 24 * 60 * 60 _000 ) {
return obs ;
}
}
} catch { /* miss */ }
if ( ! fredKey ) return null ;
try {
const adapter = new FredAdapterImpl ( fredKey );
const entry = await adapter . series ( seriesId );
const series = entry . value as { observations? : FredObs } | null ;
const obs = series ? . observations ;
if ( ! obs ? . length ) return null ;
const now = new Date (). toISOString ();
try {
ctx . db . prepare (
'INSERT OR REPLACE INTO kv_cache (key, value, observed_at) VALUES (?,?,?)' ,
). run ( cacheKey , JSON . stringify ({ seriesId , observations : obs }), now );
} catch { /* best-effort cache; may race under SQLite */ }
return obs ;
} catch {
return null ;
}
}
function lastValue ( obs : FredObs | null ) : number | null {
if ( ! obs ? . length ) return null ;
const v = obs [ obs . length - 1 ]. value ;
return Number . isFinite ( v ) ? v : null ;
}
/** YoY % from monthly index series (needs ~13 observations). */
function yoyPct ( obs : FredObs | null ) : number | null {
if ( ! obs || obs . length < 13 ) return lastValue ( obs );
const last = obs [ obs . length - 1 ]. value ;
const yearAgo = obs [ obs . length - 13 ]. value ;
if ( ! yearAgo || yearAgo === 0 ) return null ;
return (( last - yearAgo ) / yearAgo ) * 100 ;
}
// Parallel FRED pulls (rates, labor, inflation).
const [ unrateObs , gs10Obs , gs2Obs , cpiObs ] = await Promise . all ([
fredObs ( FRED_SERIES . UNEMPLOYMENT ),
fredObs ( FRED_SERIES . TREASURY_10Y ),
fredObs ( FRED_SERIES . TREASURY_2Y ),
fredObs ( FRED_SERIES . CPI ),
]);
factors . unemployment = lastValue ( unrateObs );
factors . treasury10Y = lastValue ( gs10Obs );
factors . treasury2Y = lastValue ( gs2Obs );
factors . cpi = yoyPct ( cpiObs ); // classifier expects inflation %, not CPI index level
if ( factors . treasury10Y !== null && factors . treasury2Y !== null ) {
factors . curve10y2y = factors . treasury10Y - factors . treasury2Y ;
}
// SPY trend + VIX from yfinance (subscribe if cold).
try {
try { await ctx . cache . subscribe ( 'SPY' , 'etf' ); } catch { /* ignore */ }
const spyEntry = await ctx . cache . get < PriceCandle [] >( 'yfinance:candles:SPY:1d' );
const spy = ( spyEntry ? . value ?? []) as PriceCandle [];
factors . spy1M = totalReturnPct ( spy , 30 * 86 _400_000 );
if ( spy . length >= 60 ) {
const last = spy [ spy . length - 1 ]. c ;
const ago = spy [ spy . length - 50 ]. c ;
if ( ago > 0 ) factors . spyEmaSlope = (( last - ago ) / ago ) * 100 ;
}
} catch { /* ignore */ }
try {
const vixEntry = await ctx . cache . get < Quote >( 'yfinance:quote:^VIX' );
if ( vixEntry ? . value ? . price != null ) factors . vix = vixEntry . value . price ;
else {
const vixC = await ctx . cache . get < PriceCandle [] >( 'yfinance:candles:^VIX:1d' );
const arr = vixC ? . value as PriceCandle [] | undefined ;
if ( arr ? . length ) factors . vix = arr [ arr . length - 1 ]. c ;
}
// Live fallback via yahoo-finance2 if still missing.
if ( factors . vix == null ) {
try {
const mod = await import ( 'yahoo-finance2' );
const yf = new mod . default ();
const q = await yf . quote ( '^VIX' ) as { regularMarketPrice? : number };
if ( typeof q ? . regularMarketPrice === 'number' ) factors . vix = q . regularMarketPrice ;
} catch { /* ignore */ }
}
} catch { /* ignore */ }
// Yield fallback from Yahoo if FRED key missing/failed (^TNX ≈ 10Y yield).
if ( factors . treasury10Y == null ) {
try {
const mod = await import ( 'yahoo-finance2' );
const yf = new mod . default ();
const q = await yf . quote ( '^TNX' ) as { regularMarketPrice? : number };
if ( typeof q ? . regularMarketPrice === 'number' ) factors . treasury10Y = q . regularMarketPrice ;
} catch { /* ignore */ }
}
const classification = classifyRegime ({
gdpGrowth : factors.gdpGrowth ?? undefined ,
cpi : factors.cpi ?? undefined ,
unemployment : factors.unemployment ?? undefined ,
treasury10Y : factors.treasury10Y ?? undefined ,
vix : factors.vix ?? undefined ,
spyEmaSlope : factors.spyEmaSlope ?? undefined ,
});
type RiskFlag = { id : string ; label : string ; severity : 'info' | 'warning' | 'elevated' ; detail : string };
const risks : RiskFlag [] = [];
if ( factors . curve10y2y !== null && factors . curve10y2y < 0 ) {
risks . push ({
id : 'curve_inverted' ,
label : 'Yield curve inverted' ,
severity : 'warning' ,
detail : `10Y− 2Y is ${ factors . curve10y2y . toFixed ( 2 ) } pp. Curve inversions have historically preceded slower growth — a risk flag, not a timer.` ,
});
}
if ( factors . vix !== null && factors . vix >= 25 ) {
risks . push ({
id : 'vol_elevated' ,
label : 'Elevated volatility' ,
severity : factors.vix >= 30 ? 'elevated' : 'warning' ,
detail : `VIX near ${ factors . vix . toFixed ( 1 ) } . Higher implied fear often coincides with wider daily ranges; sizing frameworks typically reduce risk fractions in this environment.` ,
});
}
if ( factors . spy1M !== null && factors . spy1M <= - 8 ) {
risks . push ({
id : 'spy_drawdown' ,
label : 'Broad market soft (1M)' ,
severity : 'warning' ,
detail : `SPY about ${ factors . spy1M . toFixed ( 1 ) } % over the last month. Review drawdown tolerance and thesis integrity before adding risk.` ,
});
}
if ( factors . unemployment !== null && factors . unemployment >= 5.5 ) {
risks . push ({
id : 'labor_soft' ,
label : 'Labor market softer' ,
severity : 'info' ,
detail : `Unemployment near ${ factors . unemployment . toFixed ( 1 ) } %. Track whether the trend is rising or stabilizing.` ,
});
}
if ( risks . length === 0 ) {
risks . push ({
id : 'no_flags' ,
label : 'No elevated flags from available data' ,
severity : 'info' ,
detail : 'Available series do not show an inverted curve, elevated VIX, or a sharp one-month broad-market drawdown. Missing data is not the same as absence of risk.' ,
});
}
const regimePlain =
classification . regime === 'trending-up' ? 'Trending up'
: classification . regime === 'trending-down' ? 'Trending down'
: 'Range-bound' ;
return {
regime : classification.regime ,
regimePlain ,
confidence : classification.confidence ,
factors : classification.factors ,
explanation : classification.explanation ,
metrics : {
spy1M : factors.spy1M ,
vix : factors.vix ,
treasury10Y : factors.treasury10Y ,
treasury2Y : factors.treasury2Y ,
curve10y2y : factors.curve10y2y ,
unemployment : factors.unemployment ,
},
risks ,
updatedAt : new Date (). toISOString (),
lesson : 'Regime and risk flags summarize current data. They provide process context — not forecasts or allocation instructions.' ,
};
}),
rotationCheckForAlert : publicProcedure.mutation ( async ({ ctx }) => {
const {
MARKET_ROTATION_UNIVERSE ,
BENCHMARK_SYMBOL ,
buildSectorRsMap ,
summarizeRotation ,
} = await import ( '../analysis/marketRotationRs.ts' );
const symbols = [ BENCHMARK_SYMBOL , ... MARKET_ROTATION_UNIVERSE . map (( s ) => s . symbol )];
for ( const sym of symbols ) {
try { await ctx . cache . subscribe ( sym , 'etf' ); } catch { /* ignore */ }
}
const keys = symbols . map (( s ) => `yfinance:candles: ${ s } :1d` );
const entries = await ctx . cache . getMany < PriceCandle [] >( keys );
const map : Record < string , PriceCandle [] > = {};
symbols . forEach (( s , i ) => {
map [ s ] = ( entries [ i ] ? . value ?? []) as PriceCandle [];
});
const rows = buildSectorRsMap (
MARKET_ROTATION_UNIVERSE ,
map ,
map [ BENCHMARK_SYMBOL ] ?? [],
);
const summary = summarizeRotation ( rows );
const signal = summary . strength ;
const spread = summary . leadershipSpread ;
const prev = ctx . db . prepare ( 'SELECT signal, checked_at, signal_since FROM rotation_state WHERE id=?' ). get ( 'singleton' ) as { signal : string ; checked_at : string | null ; signal_since : string | null } | undefined ;
const now = new Date (). toISOString ();
const signalSince = prev ? . signal === signal ? ( prev . signal_since ?? now ) : now ;
ctx . db . prepare (
`INSERT INTO rotation_state (id, signal, spread, inflow_avg, outflow_avg, checked_at, signal_since)
VALUES ('singleton', ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET signal=excluded.signal, spread=excluded.spread, inflow_avg=excluded.inflow_avg, outflow_avg=excluded.outflow_avg, checked_at=excluded.checked_at, signal_since=excluded.signal_since`
). run ( signal , spread , summary . leadingCount , summary . laggingCount , now , signalSince );
const alertsCreated : string [] = [];
if (( signal === 'moderate' || signal === 'strong' ) && prev ? . signal !== signal ) {
const { createAlert } = await import ( '../alerts/AlertEngine.ts' );
const userIds = ctx . db . prepare ( 'SELECT id FROM users' ). all () as Array < { id : string } > ;
for ( const u of userIds ) {
const deductionKey = `rotation_incipient: ${ signal } : ${ summary . leadingGroup } ` ;
const existing = ctx . db . prepare ( 'SELECT id FROM alert_events WHERE dedup_key=? AND user_id=?' ). get ( deductionKey , u . id );
if ( existing ) continue ;
const alert = createAlert (
crypto . randomUUID (), u . id , 'rotation_incipient' as any , undefined ,
`Relative leadership may be shifting ( ${ signal } ). Leading: ${ summary . leadingGroup } . Lagging: ${ summary . laggingGroup } . Spread ~ ${ spread . toFixed ( 1 ) } pp vs SPY. Educational notice — not an allocation instruction.` ,
signal ,
{ spread , signal , leadingGroup : summary.leadingGroup , laggingGroup : summary.laggingGroup },
);
ctx . db . prepare (
`INSERT INTO alert_events (id, user_id, type, severity, title, description, created_at, acknowledged, dedup_key, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
). run ( alert . id , u . id , alert . type , alert . severity , alert . title , alert . description , alert . createdAt , alert . dedupKey , JSON . stringify ( alert . payload ));
alertsCreated . push ( u . id );
}
}
return {
signal ,
spread ,
inflowAvg : summary.leadingCount ,
outflowAvg : summary.laggingCount ,
prevSignal : prev?.signal ?? null ,
alertsCreated : alertsCreated.length ,
leadingGroup : summary.leadingGroup ,
laggingGroup : summary.laggingGroup ,
};
}),
/** Relative-strength rotation map (vs SPY). Not absolute-return "flow". */
rotation : publicProcedure.query ( async ({ ctx }) => {
const {
MARKET_ROTATION_UNIVERSE ,
BENCHMARK_SYMBOL ,
buildSectorRsMap ,
summarizeRotation ,
} = await import ( '../analysis/marketRotationRs.ts' );
const symbols = [ BENCHMARK_SYMBOL , ... MARKET_ROTATION_UNIVERSE . map (( s ) => s . symbol )];
for ( const sym of symbols ) {
try { await ctx . cache . subscribe ( sym , 'etf' ); } catch { /* ignore */ }
}
const keys = symbols . map (( s ) => `yfinance:candles: ${ s } :1d` );
const entries = await ctx . cache . getMany < PriceCandle [] >( keys );
const map : Record < string , PriceCandle [] > = {};
let dataPoints = 0 ;
symbols . forEach (( s , i ) => {
const candles = ( entries [ i ] ? . value ?? []) as PriceCandle [];
map [ s ] = candles ;
if ( candles . length > 0 ) dataPoints += 1 ;
});
const rows = buildSectorRsMap (
MARKET_ROTATION_UNIVERSE ,
map ,
map [ BENCHMARK_SYMBOL ] ?? [],
);
const summary = summarizeRotation ( rows );
const state = ctx . db . prepare ( 'SELECT signal_since FROM rotation_state WHERE id=?' ). get ( 'singleton' ) as { signal_since : string | null } | undefined ;
const signalSince = state ? . signal_since ?? null ;
const daysSince = signalSince ? Math . round (( Date . now () - new Date ( signalSince ). getTime ()) / 86 _400_000 ) : null ;
// Backward-compatible sector cards (group = leadership, not abs-return inflow).
const sectors = rows . map (( r ) => ({
symbol : r . symbol ,
name : r.name ,
group : r.leadership === 'leading' ? 'inflow' as const
: r . leadership === 'lagging' ? 'outflow' as const
: 'inflow' as const , // inline treated as neutral; UI will use leadership field
subGroup : r.group ,
kind : r.kind ,
leadership : r.leadership ,
earlyWatch : r.earlyWatch ,
rank1M : r.rank1M ,
rank1W : r.rank1W ,
relVol : r.relVol ,
oneWeek : r.abs.oneWeek ,
oneMonth : r.abs.oneMonth ,
threeMonth : r.abs.threeMonth ,
sixMonth : r.abs.sixMonth ,
oneYear : r.abs.oneYear ,
rsOneWeek : r.rs.oneWeek ,
rsOneMonth : r.rs.oneMonth ,
rsThreeMonth : r.rs.threeMonth ,
rsSixMonth : r.rs.sixMonth ,
rsOneYear : r.rs.oneYear ,
}));
const signal = {
strength : summary.strength ,
spread : summary.leadershipSpread ,
// Legacy field names kept for RotationSignalAlert; mean stronger/weaker counts.
inflowAvg : summary.leadingCount ,
outflowAvg : summary.laggingCount ,
leadingSubGroup : summary.leadingGroup ,
laggingSubGroup : summary.laggingGroup ,
inflowConfirmation : ` ${ summary . leadingCount } / ${ rows . length } ` ,
outflowConfirmation : ` ${ summary . laggingCount } / ${ rows . length } ` ,
earlyWatchCount : summary.earlyWatchCount ,
daysSince ,
summary : summary.summary ,
};
// Persist today's rank snapshot so we can show history later.
try {
const asOf = new Date (). toISOString (). slice ( 0 , 10 );
const ins = ctx . db . prepare (
`INSERT OR REPLACE INTO rotation_rank_snapshots
(as_of_date, symbol, name, grp, rank_1m, rs_1m, rank_1w, rs_1w, leadership, early_watch, strength)
VALUES (?,?,?,?,?,?,?,?,?,?,?)` ,
);
for ( const r of rows ) {
ins . run (
asOf , r . symbol , r . name , r . group ,
r . rank1M , r . rs . oneMonth , r . rank1W , r . rs . oneWeek ,
r . leadership , r . earlyWatch ? 1 : 0 , summary . strength ,
);
}
} catch { /* table may not exist until migration; ignore */ }
// Recent early-watch history (last 14 days).
let earlyHistory : Array < { date : string ; symbol : string ; name : string ; rs1w : number | null } > = [];
try {
earlyHistory = ctx . db . prepare (
`SELECT as_of_date AS date, symbol, name, rs_1w AS rs1w
FROM rotation_rank_snapshots
WHERE early_watch = 1 AND as_of_date >= date('now', '-14 day')
ORDER BY as_of_date DESC, rank_1w ASC
LIMIT 40` ,
). all () as Array < { date : string ; symbol : string ; name : string ; rs1w : number | null } > ;
} catch { /* ignore */ }
return {
sectors ,
signal ,
benchmark : BENCHMARK_SYMBOL ,
coveredSymbols : dataPoints ,
totalSymbols : symbols.length ,
earlyHistory ,
fetchedAt : new Date (). toISOString (),
lesson : 'Relative strength compares each ETF to the broad market (SPY). Outperformance means higher return than SPY over the window — not measured ETF share creation or redemption flows.' ,
};
}),
/** Historical seasonality for SPY (and optional symbol) + simple calendar. */
seasonality : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ). max ( 12 ). optional () }). optional ())
. query ( async ({ ctx , input }) => {
const { buildSeasonalitySnapshot , upcomingSimpleEvents } = await import ( '../analysis/seasonality.ts' );
const symbol = ( input ? . symbol ?? 'SPY' ). toUpperCase ();
try { await ctx . cache . subscribe ( symbol , symbol === 'SPY' ? 'etf' : 'equity' ); } catch { /* ignore */ }
const entry = await ctx . cache . get < PriceCandle [] >( `yfinance:candles: ${ symbol } :1d` );
const candles = ( entry ? . value ?? []) as PriceCandle [];
const snapshot = buildSeasonalitySnapshot (
symbol ,
candles . map (( c ) => ({ ts : c.ts , c : c.c })),
);
return {
... snapshot ,
events : upcomingSimpleEvents (),
hasEnoughHistory : candles.length >= 250 ,
lesson : 'Historical monthly averages are context only. They do not forecast the current year.' ,
};
}),
/**
* Top holdings for a sector/theme ETF (e.g. SMH, XLK).
* Resolution order: fresh kv_cache → live Yahoo → stale cache → static fallback.
* Day quotes from local `quotes` only (no Yahoo chart/quote fanout under rate limits).
*/
sectorHoldings : publicProcedure
. input ( z . object ({ etfSymbol : z.string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const { staticHoldingsFor } = await import ( '../analysis/etfHoldingsFallback.ts' );
const symbol = input . etfSymbol . toUpperCase ();
const cacheKey = `etf:topHoldings: ${ symbol } ` ;
const FRESH_MS = 24 * 60 * 60 _000 ;
type HoldingRow = { symbol : string ; holdingName : string ; holdingPercent : number };
function readCachedComposition () : { rows : HoldingRow []; observedAt : string } | null {
try {
const row = ctx . db . prepare ( 'SELECT value, observed_at FROM kv_cache WHERE key=?' ). get ( cacheKey ) as
| { value : string ; observed_at : string } | undefined ;
if ( ! row ? . value ) return null ;
const parsed = JSON . parse ( row . value ) as { holdings? : HoldingRow [] };
const rows = ( parsed . holdings ?? [])
. filter (( h ) => h ? . symbol )
. map (( h ) => ({
symbol : String ( h . symbol ). toUpperCase (),
holdingName : h.holdingName ?? String ( h . symbol ),
holdingPercent : Number ( h . holdingPercent ) || 0 ,
}));
if ( ! rows . length ) return null ;
return { rows , observedAt : row.observed_at };
} catch {
return null ;
}
}
function writeCachedComposition ( rows : HoldingRow []) : void {
try {
ctx . db . prepare (
'INSERT OR REPLACE INTO kv_cache (key, value, observed_at) VALUES (?,?,?)' ,
). run ( cacheKey , JSON . stringify ({ symbol , holdings : rows }), new Date (). toISOString ());
} catch { /* best-effort; SQLite lock under concurrent write is fine */ }
}
function fromStatic () : HoldingRow [] {
return staticHoldingsFor ( symbol ). map (( h ) => ({
symbol : h . symbol . toUpperCase (),
holdingName : h.holdingName ,
holdingPercent : h.holdingPercent ,
}));
}
let holdings : HoldingRow [] = [];
let source : 'live' | 'cache' | 'static' | 'empty' = 'empty' ;
const cached = readCachedComposition ();
const cacheAgeMs = cached ? Date . now () - Date . parse ( cached . observedAt ) : Infinity ;
const cacheFresh = Number . isFinite ( cacheAgeMs ) && cacheAgeMs < FRESH_MS ;
if ( cacheFresh && cached ) {
holdings = cached . rows ;
source = 'cache' ;
} else if ( cached ) {
// Stale composition is fine for a peek panel; do not block on Yahoo under rate limits.
holdings = cached . rows ;
source = 'cache' ;
} else {
// Cold path: static first (instant), then optional short Yahoo upgrade.
const fallback = fromStatic ();
if ( fallback . length > 0 ) {
holdings = fallback ;
source = 'static' ;
writeCachedComposition ( fallback );
}
try {
const mod = await import ( 'yahoo-finance2' );
const yf = new mod . default ();
const summary = await Promise . race ([
yf . quoteSummary ( symbol , { modules : [ 'topHoldings' ] }) as Promise < {
topHoldings ?: { holdings? : HoldingRow [] };
} > ,
new Promise < never >(( _ , reject ) => {
setTimeout (() => reject ( new Error ( 'yahoo-topHoldings-timeout' )), 2500 );
}),
]);
const live = ( summary ? . topHoldings ? . holdings ?? [])
. filter (( h ) => h ? . symbol )
. map (( h ) => ({
symbol : String ( h . symbol ). toUpperCase (),
holdingName : h.holdingName ?? String ( h . symbol ),
holdingPercent : Number ( h . holdingPercent ) || 0 ,
}));
if ( live . length > 0 ) {
holdings = live ;
source = 'live' ;
writeCachedComposition ( live );
}
} catch {
/* rate limit / timeout / network — keep static or empty */
}
}
if ( holdings . length === 0 ) {
return { symbol , holdings : [], source : 'empty' as const };
}
const symbols = holdings . map (( h ) => h . symbol . toUpperCase ());
// Local quotes only — never open another Yahoo batch from this endpoint.
const quoteMap = new Map < string , {
price : number | null ;
change : number | null ;
changePercent : number | null ;
volume : number | null ;
marketCap : number | null ;
}>();
try {
const placeholders = symbols . map (() => '?' ). join ( ',' );
const rows = ctx . db . prepare (
`SELECT symbol, price, change, change_pct FROM quotes WHERE symbol IN ( ${ placeholders } )` ,
). all (... symbols ) as Array < {
symbol : string ;
price : number | null ;
change : number | null ;
change_pct : number | null ;
} > ;
for ( const r of rows ) {
quoteMap . set ( r . symbol . toUpperCase (), {
price : r.price ,
change : r.change ,
changePercent : r.change_pct ,
volume : null ,
marketCap : null ,
});
}
} catch { /* local quotes optional */ }
const result = holdings . map (( h ) => {
const sym = h . symbol . toUpperCase ();
const q = quoteMap . get ( sym );
return {
symbol : sym ,
name : h.holdingName ,
weight : h.holdingPercent ,
price : q?.price ?? null ,
change : q?.change ?? null ,
changePercent : q?.changePercent ?? null ,
volume : q?.volume ?? null ,
marketCap : q?.marketCap ?? null ,
returns1W : null as number | null ,
returns1M : null as number | null ,
returns3M : null as number | null ,
returns6M : null as number | null ,
returns1Y : null as number | null ,
};
});
return { symbol , holdings : result , source };
}),
2026-06-29 17:40:42 -04:00
});
2026-07-23 18:02:24 -04:00
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 ;
}
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 )),
2026-07-25 11:38:23 -04:00
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.' }); }
}),
2026-07-05 08:50:12 -04:00
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 )),
2026-07-12 12:55:59 -04:00
userSessions : adminProcedure
. input ( z . object ({ userId : z.string (). uuid () }))
. query (({ ctx , input }) => listUserSessions ( ctx . db , input . userId )),
auditLog : adminProcedure
. input ( z . object ({
limit : z.number (). int (). min ( 1 ). max ( 200 ). default ( 50 ),
offset : z.number (). int (). min ( 0 ). default ( 0 ),
actor : z.string (). optional (),
action : z.string (). optional (),
}))
. query (({ ctx , input }) => listAuditLog ( ctx . db , { limit : input.limit , offset : input.offset , actor : input.actor ?? null , action : input.action ?? null })),
queueSecFetch : adminProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ). max ( 10 ) }))
. mutation (({ ctx , input }) => {
try { queueSecFetch ( ctx . db , input . symbol ); return { ok : true }; }
catch ( e ) { throw new TRPCError ({ code : 'BAD_REQUEST' , message : e instanceof Error ? e . message : 'Failed to queue fetch.' }); }
}),
queueStatus : adminProcedure.query (({ ctx }) => ctx . queue . health ()),
queuePause : adminProcedure.mutation (({ ctx }) => {
ctx . queue . setPaused ( true );
return { paused : true };
}),
queueResume : adminProcedure.mutation (({ ctx }) => {
ctx . queue . setPaused ( false );
return { paused : false };
}),
queueLogs : adminProcedure
. input ( z . object ({ key : z.string (). min ( 1 ), limit : z.number (). int (). min ( 1 ). max ( 100 ). optional (). default ( 10 ) }))
. query (({ ctx , input }) => ctx . queue . getErrorLog ( input . key , input . limit )),
queueRetryJob : adminProcedure
. input ( z . object ({ key : z.string (). min ( 1 ) }))
. mutation (({ ctx , input }) => {
ctx . queue . retryJob ( input . key );
return { ok : true };
}),
queueRetrySource : adminProcedure
. input ( z . object ({ sourceKind : z.string (). min ( 1 ) }))
. mutation (({ ctx , input }) => {
const cleared = ctx . queue . retrySource ( input . sourceKind );
return { cleared };
}),
queueClearDone : adminProcedure
. input ( z . object ({ olderThanHours : z.number (). min ( 1 ). max ( 336 ). optional (). default ( 24 ) }))
. mutation (({ ctx , input }) => {
const cleared = ctx . queue . clearDone ( input . olderThanHours * 3600000 );
return { cleared };
}),
queueSchedules : adminProcedure.query (({ ctx }) => ctx . queue . listSchedules ()),
queueSetSchedule : adminProcedure
. input ( z . object ({ sourceKind : z.string (). min ( 1 ), intervalMs : z.number (). int (). min ( 60000 ). max ( 604800000 ) }))
. mutation (({ ctx , input }) => {
ctx . queue . setSchedule ( input . sourceKind , input . intervalMs );
return { ok : true };
}),
queueDeleteSchedule : adminProcedure
. input ( z . object ({ sourceKind : z.string (). min ( 1 ) }))
. mutation (({ ctx , input }) => {
ctx . queue . deleteSchedule ( input . sourceKind );
return { ok : true };
}),
2026-07-12 20:19:12 -04:00
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 }) => {
2026-07-23 18:02:24 -04:00
const { lintInstitutionalHolders , lintInsiderTransactions , lintSCFilings } = await import ( '../services/secDataFetcher.ts' );
2026-07-12 20:19:12 -04:00
let result : LintResult ;
if ( input . kind === 'sec-lint-holders' ) {
result = await lintInstitutionalHolders ( ctx . db , input . symbol );
2026-07-23 18:02:24 -04:00
await lintSCFilings ( ctx . db , input . symbol ). catch (() => {});
2026-07-12 20:19:12 -04:00
} 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' ;
2026-07-23 18:02:24 -04:00
const params : SQLInputValue [] = [];
2026-07-12 20:19:12 -04:00
if ( input . symbol ) { sql += ' AND symbol = ?' ; params . push ( input . symbol ); }
if ( input . kind ) { sql += ' AND kind = ?' ; params . push ( input . kind ); }
sql += ' ORDER BY last_checked_at DESC LIMIT 200' ;
return ctx . db . prepare ( sql ). all (... params ) as Array < Record < string , unknown >>;
}),
queueLintAll : adminProcedure
. input ( z . object ({ kind : z.enum ([ 'sec-lint-holders' , 'sec-lint-insiders' ]) }))
. mutation ( async ({ ctx , input }) => {
const { lintInstitutionalHolders , lintInsiderTransactions } = await import ( '../services/secDataFetcher.ts' );
const symbols = ( ctx . db . prepare ( 'SELECT symbol FROM symbol_demand WHERE in_demand=1 ORDER BY symbol' ). all () as Array < { symbol : string } > ). map (( r ) => r . symbol );
const results : LintResult [] = [];
for ( const sym of symbols ) {
try {
if ( input . kind === 'sec-lint-holders' ) {
results . push ( await lintInstitutionalHolders ( ctx . db , sym ));
} else {
results . push ( await lintInsiderTransactions ( ctx . db , sym ));
}
} catch ( e ) {
const msg = e instanceof Error ? e.message : String ( e );
results . push ({ symbol : sym . toUpperCase (), kind : input.kind === 'sec-lint-holders' ? 'institution_filings' : 'insider_transactions' , discoveredCount : 0 , storedCount : 0 , missingCount : 0 , backfilled : 0 , stale : 1 , status : 'error' , detail : { reason : msg } });
}
}
return { total : symbols.length , results };
}),
2026-07-23 18:02:24 -04:00
// ---------------------------------------------------------------------------
// 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 };
}),
2026-07-23 20:51:47 -04:00
/** Get SMTP config (admin-only). */
smtpConfig : adminProcedure.query ( async ({ ctx }) => {
const { readSmtpConfig } = await import ( '../services/emailAlertService.ts' );
return readSmtpConfig ( ctx . db ) ?? { enabled : false };
}),
/** Update SMTP config (admin-only). */
smtpConfigUpdate : adminProcedure
. input ( z . object ({
host : z.string (). optional (),
port : z.number (). int (). optional (),
secure : z.boolean (). optional (),
user : z.string (). optional (). nullable (),
pass : z.string (). optional (). nullable (),
fromName : z.string (). optional (),
fromEmail : z.string (). optional (),
enabled : z.boolean (). optional (),
}))
. mutation (({ ctx , input }) => {
const config = ctx . db . prepare ( 'SELECT * FROM smtp_config WHERE id = ?' ). get ( 'singleton' ) as Record < string , unknown > | undefined ;
const now = new Date (). toISOString ();
if ( ! config ) {
ctx . db . prepare (
`INSERT INTO smtp_config (id, host, port, secure, user, pass_enc, from_name, from_email, enabled, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
). run ( 'singleton' , input . host ?? 'smtp.mail.me.com' , input . port ?? 587 , input . secure ? 1 : 0 , input . user ?? null , input . pass ?? null , input . fromName ?? 'Investor Flow' , input . fromEmail ?? '' , input . enabled ? 1 : 0 , now );
} else {
const updates : string [] = [];
const params : ( string | number | null )[] = [];
if ( input . host !== undefined ) { updates . push ( 'host = ?' ); params . push ( input . host ); }
if ( input . port !== undefined ) { updates . push ( 'port = ?' ); params . push ( input . port ); }
if ( input . secure !== undefined ) { updates . push ( 'secure = ?' ); params . push ( input . secure ? 1 : 0 ); }
if ( input . user !== undefined ) { updates . push ( 'user = ?' ); params . push ( input . user ); }
if ( input . pass !== undefined ) { updates . push ( 'pass_enc = ?' ); params . push ( input . pass ); }
if ( input . fromName !== undefined ) { updates . push ( 'from_name = ?' ); params . push ( input . fromName ); }
if ( input . fromEmail !== undefined ) { updates . push ( 'from_email = ?' ); params . push ( input . fromEmail ); }
if ( input . enabled !== undefined ) { updates . push ( 'enabled = ?' ); params . push ( input . enabled ? 1 : 0 ); }
updates . push ( 'updated_at = ?' );
params . push ( now );
params . push ( 'singleton' );
ctx . db . prepare ( `UPDATE smtp_config SET ${ updates . join ( ', ' ) } WHERE id = ?` ). run (... params );
}
return { ok : true };
}),
/** Test SMTP config by sending a test email to the admin. */
smtpConfigTest : adminProcedure.mutation ( async ({ ctx }) => {
const { readSmtpConfig , sendAlertEmail } = await import ( '../services/emailAlertService.ts' );
const config = readSmtpConfig ( ctx . db );
if ( ! config || ! config . enabled ) throw new TRPCError ({ code : 'BAD_REQUEST' , message : 'SMTP not configured or disabled.' });
const testAlert = {
id : 'test' ,
userId : ctx.userId as string ,
type : 'rotation_incipient' as const ,
severity : 'info' as const ,
title : 'Test alert from Investor Flow' ,
description : 'This is a test email to verify SMTP configuration.' ,
symbol : undefined ,
createdAt : new Date (). toISOString (),
acknowledged : false ,
dedupKey : 'test:' + Date . now (),
payload : {},
};
const sent = await sendAlertEmail ( ctx . db , testAlert );
if ( ! sent ) throw new TRPCError ({ code : 'INTERNAL_SERVER_ERROR' , message : 'Failed to send test email.' });
return { ok : true };
}),
2026-07-05 08:50:12 -04:00
});
// Slice 8 — Institutional Dashboard Rollup (read-only, no trade actions).
const dashboardRouter = router ({
rollup : publicProcedure
. input ( z . object ({}))
. query ( async ({ ctx }) => {
2026-07-12 12:55:59 -04:00
const userId = ctx . userId ?? 'anonymous' ;
2026-07-05 08:50:12 -04:00
// 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 };
}),
2026-07-23 20:51:47 -04:00
/** 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 };
}),
2026-07-05 08:50:12 -04:00
});
// ─── 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,
2026-07-23 18:02:24 -04:00
reported_quarter, filed_at, put_call
2026-07-05 08:50:12 -04:00
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 ;
2026-07-23 18:02:24 -04:00
put_call : string | null ;
2026-07-05 08:50:12 -04:00
} > ;
if ( rows . length === 0 ) {
2026-07-12 12:55:59 -04:00
return { symbol , flow : [], quarters : [], filings : [] };
2026-07-05 08:50:12 -04:00
}
// 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 ();
2026-07-12 12:55:59 -04:00
// 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 ,
2026-07-23 18:02:24 -04:00
putCall : r.put_call ?? '' ,
2026-07-12 12:55:59 -04:00
}));
return { symbol , flow : allFlow , quarters , filings };
2026-07-05 08:50:12 -04:00
}),
/** Get insider activity stream for a symbol (M5 quarterly price strip). */
insiderStream : publicProcedure
. input ( z . object ({
symbol : z . string (). min ( 1 ),
2026-07-12 12:55:59 -04:00
limit : z.number (). int (). min ( 1 ). max ( 500 ). optional (). default ( 200 ),
2026-07-05 08:50:12 -04:00
}))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
2026-07-12 12:55:59 -04:00
// 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 );
2026-07-05 08:50:12 -04:00
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
2026-07-12 12:55:59 -04:00
WHERE symbol = ? AND tx_date >= ?
2026-07-05 08:50:12 -04:00
ORDER BY tx_date DESC
LIMIT ?
2026-07-12 12:55:59 -04:00
` ). all ( symbol , since , input . limit ) as Array < {
2026-07-05 08:50:12 -04:00
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 {
2026-07-12 12:55:59 -04:00
reporter : tx.insider_name ,
relationship : tx.insider_role ,
transactionDate : tx.tx_date ,
transactionCode : tx.tx_code ,
transactionType : tx.tx_type ,
2026-07-05 08:50:12 -04:00
shares ,
price : tx.price ,
is10b5 : tx.is_10b5_1 === 1 ,
classification : tx.classification ,
filedAt : tx.filed_at ,
};
});
return {
symbol ,
events ,
netShares ,
count : events.length ,
};
}),
2026-07-23 18:02:24 -04:00
/** Aggregate institutional ownership by month for overlay charts. */
ownershipHistory : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
const rows = ctx . db . prepare ( `
SELECT substr(filed_at, 1, 7) as month, SUM(shares) as total_shares, COUNT(DISTINCT filer_cik) as num_filers
FROM institution_filings
WHERE symbol = ? AND shares IS NOT NULL
GROUP BY month
ORDER BY month ASC
` ). all ( symbol ) as Array < { month : string ; total_shares : number ; num_filers : number } > ;
return { symbol , months : rows };
}),
/** Per-filer buy events computed from QoQ 13F delta. */
buyEvents : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
const rows = ctx . db . prepare ( `
SELECT filer_cik, filer_name, reported_quarter, shares, value_usd
FROM institution_filings
WHERE symbol = ? AND shares IS NOT NULL AND value_usd IS NOT NULL
ORDER BY filer_cik, reported_quarter ASC
` ). all ( symbol ) as Array < {
filer_cik : string ; filer_name : string | null ;
reported_quarter : string ; shares : number ; value_usd : number ;
} > ;
const byFiler = new Map < string , typeof rows >();
for ( const r of rows ) {
const arr = byFiler . get ( r . filer_cik ) ?? [];
arr . push ( r );
byFiler . set ( r . filer_cik , arr );
}
const events : Array < {
filerName : string | null ; filerCik : string ;
sharesAdded : number ; estimatedPrice : number ; date : string ;
} > = [];
for ( const [, filerRows ] of byFiler ) {
for ( let i = 1 ; i < filerRows . length ; i ++ ) {
const prev = filerRows [ i - 1 ];
const curr = filerRows [ i ];
const delta = curr . shares - prev . shares ;
if ( delta > 0 ) {
const price = curr . value_usd > 0 ? curr . value_usd / curr.shares : 0 ;
const parts = curr . reported_quarter . split ( '-Q' );
const y = parseInt ( parts [ 0 ]);
const q = parseInt ( parts [ 1 ]);
const month = q * 3 ; // Q1=3, Q2=6, Q3=9, Q4=12
const lastDay = new Date ( y , month , 0 ). getDate ();
const date = ` ${ y } - ${ String ( month ). padStart ( 2 , '0' ) } - ${ String ( lastDay ). padStart ( 2 , '0' ) } ` ;
events . push ({ filerName : curr.filer_name , filerCik : curr.filer_cik , sharesAdded : delta , estimatedPrice : price , date });
}
}
}
return { symbol , events };
}),
/** Analyst ratings (upgrades/downgrades + consensus) from yahoo-finance2. */
analystRatings : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
const { fetchAndStoreAnalystRatings , getAnalystRatings } = await import ( '../services/analystRatingsService.ts' );
// Try cache first
const cached = getAnalystRatings ( ctx . db , symbol );
if ( cached && ! cached . stale ) return { symbol , ratings : cached.ratings , consensus : cached.consensus };
// Fetch fresh
const result = await fetchAndStoreAnalystRatings ( ctx . db , symbol );
if ( 'error' in result ) {
// If we have cached data, serve it stale rather than erroring
if ( cached ) return { symbol , ratings : cached.ratings , consensus : cached.consensus , stale : true };
return { symbol , ratings : [], consensus : null , error : result.error };
}
2026-07-25 12:09:21 -04:00
return { symbol , ratings : result.ratings , consensus : result.consensus };
}),
/** Short interest (shares short, % float, short ratio) from yahoo-finance2 defaultKeyStatistics. */
shortInterest : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
const k = `yfinance:shortinterest: ${ symbol } ` ;
const entry = await ctx . cache . get < unknown >( k );
const value = entry . value as Record < string , unknown > | null ;
return {
symbol ,
sharesShort : value ? ( value . sharesShort as number | null ) : null ,
sharesShortPriorMonth : value ? ( value . sharesShortPriorMonth as number | null ) : null ,
sharesPercentSharesOut : value ? ( value . sharesPercentSharesOut as number | null ) : null ,
shortRatio : value ? ( value . shortRatio as number | null ) : null ,
shortPercentOfFloat : value ? ( value . shortPercentOfFloat as number | null ) : null ,
dateShortInterest : value ? ( value . dateShortInterest as string | null ) : null ,
floatShares : value ? ( value . floatShares as number | null ) : null ,
sharesOutstanding : value ? ( value . sharesOutstanding as number | null ) : null ,
stale : entry.isStale ,
provenance : entry.provenance ,
};
}),
};
2026-07-05 08:50:12 -04:00
// ─── EDGAR / SEC Filings Router (Slice 6 / M6) ────────────────────────────
interface EdgarFiling {
form : string ;
dateReporter : string ;
accessionNumber : string ;
accessionNormalization : string ;
reportDate : string ;
reportFile : string ;
primaryDocument : string ;
}
interface FilerMeta {
cik : string ;
name : string | null ;
sic : string | null ;
}
interface Form13fHoldingsResult {
holdings : Array < {
cusip : string ;
issuerName : string ;
value : number ;
sshPrnamt : number ;
} > ;
2026-07-12 20:19:12 -04:00
total : number ;
2026-07-05 08:50:12 -04:00
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 });
}
}),
2026-07-12 20:19:12 -04:00
/** 13F-HR holdings for a CIK + accession (server-side paginated). */
2026-07-05 08:50:12 -04:00
form13f_holdings : publicProcedure
2026-07-12 20:19:12 -04:00
. 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 (),
}))
2026-07-05 08:50:12 -04:00
. query ( async ({ ctx , input }) => {
const edgar = new EdgarAdapter ();
try {
2026-07-12 20:19:12 -04:00
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 };
2026-07-05 08:50:12 -04:00
} 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 ({
2026-07-23 20:51:47 -04:00
/** List all symbols across all watchlists for the user. */
2026-07-05 08:50:12 -04:00
list : publicProcedure
. query ( async ({ ctx }) => {
const userId = ctx . userId ?? 'anonymous' ;
const { listSymbols } = await import ( '../db/watchlistRepository.ts' );
return listSymbols ( ctx . db , userId );
}),
2026-07-23 20:51:47 -04:00
/** 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). */
2026-07-05 08:50:12 -04:00
addSymbol : publicProcedure
2026-07-23 20:51:47 -04:00
. input ( z . object ({
symbol : z . string (). toUpperCase (),
watchlistName : z.string (). optional (),
notes : z.string (). optional (),
}))
2026-07-05 08:50:12 -04:00
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId ?? 'anonymous' ;
2026-07-25 11:38:23 -04:00
const { addSymbol , materializeClassificationWatchlists } = await import ( '../db/watchlistRepository.ts' );
2026-07-23 20:51:47 -04:00
const added = addSymbol ( ctx . db , userId , input . symbol , input . notes , input . watchlistName );
2026-07-05 08:50:12 -04:00
if ( added ) {
2026-07-25 11:38:23 -04:00
materializeClassificationWatchlists ( ctx . db , userId );
2026-07-23 18:02:24 -04:00
await ctx . cache . subscribe ( input . symbol , 'equity' );
2026-07-12 12:55:59 -04:00
queueSecFetch ( ctx . db , input . symbol );
2026-07-05 08:50:12 -04:00
}
return { added };
}),
2026-07-23 20:51:47 -04:00
/** Remove a symbol from a watchlist (defaults to 'default'). */
2026-07-05 08:50:12 -04:00
removeSymbol : publicProcedure
2026-07-23 20:51:47 -04:00
. input ( z . object ({
symbol : z . string (). toUpperCase (),
watchlistName : z.string (). optional (),
}))
2026-07-05 08:50:12 -04:00
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId ?? 'anonymous' ;
2026-07-25 11:38:23 -04:00
const { removeSymbol , materializeClassificationWatchlists } = await import ( '../db/watchlistRepository.ts' );
2026-07-23 20:51:47 -04:00
const removed = removeSymbol ( ctx . db , userId , input . symbol , input . watchlistName );
2026-07-25 11:38:23 -04:00
if ( removed ) materializeClassificationWatchlists ( ctx . db , userId );
2026-07-05 08:50:12 -04:00
return { removed };
}),
2026-07-25 11:38:23 -04:00
/** 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 };
}),
2026-07-05 08:50:12 -04:00
});
// ─── 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 };
}),
2026-07-23 18:02:24 -04:00
/** 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 };
}),
2026-07-05 08:50:12 -04:00
});
// ─── 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 (),
2026-07-23 18:02:24 -04:00
data : z.record ( z . string (), z . unknown ()). optional (). default ({}),
2026-07-05 08:50:12 -04:00
}))
. 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 ;
2026-07-23 18:02:24 -04:00
const result = canElevate ( currentState as OptionsUnlockState , input . toState as OptionsUnlockState , hasUnderstanding , true , input . toState >= 3 , input . toState === 4 );
2026-07-05 08:50:12 -04:00
if ( ! result . allowed ) throw new TRPCError ({ code : 'BAD_REQUEST' , message : result.reason });
ctx . db . prepare ( 'INSERT OR REPLACE INTO options_unlock (user_id, state, last_unlock_at, understanding) VALUES (?,?,?,?)' ). run ( userId , input . toState , new Date (). toISOString (), row ? . understanding ?? 0 );
return { state : input.toState , reason : result.reason };
}),
getPayoff : protectedProcedure
. input ( z . object ({ strike : z.number (), premium : z.number (), right : z.enum ([ 'call' , 'put' ]), underlyingPrice : z.number () }))
. query ( async ({ ctx , input }) => {
const { computePayoffDiagram } = await import ( '../options/ConvexityGate.ts' );
return computePayoffDiagram ( input . strike , input . premium , input . right , input . underlyingPrice );
}),
});
// ─── Derisking Router (Slice 23) ────────────────────────────────────────────
const deriskingRouter = router ({
suggest : protectedProcedure
. input ( z . object ({
symbol : z . string (),
currentPrice : z.number (),
avgCost : z.number (),
shares : z.number (),
ema21 : z.number (). optional (),
ema50 : z.number (). optional (),
thesisStatus : z.enum ([ 'intact' , 'weakening' , 'broken' ]). optional (),
currentRegime : z.enum ([ 'trending-up' , 'trending-down' , 'range-bound' ]). optional (),
portfolioCorrelation : z.number (). optional (),
optionsUnlockState : z.number (). optional (),
profitTargets : z.array ( z . number ()). optional (),
}))
. query ( async ({ ctx , input }) => {
const { suggestDerisking } = await import ( '../derisking/DeriskingEngine.ts' );
return { suggestions : suggestDerisking ( input ) };
}),
});
// ─── Macro Router (Slice 20) ────────────────────────────────────────────────
const macroRouter = router ({
series : protectedProcedure
. input ( z . object ({ seriesId : z.string () }))
. query ( async ({ ctx , input }) => {
const { FredAdapterImpl } = await import ( '../macro/FredAdapter.ts' );
const adapter = new FredAdapterImpl ();
return adapter . series ( input . seriesId );
}),
calendar : protectedProcedure.query ( async ({ ctx }) => {
// Return cached economic calendar events
const entry = await ctx . cache . get < unknown [] >( 'macro:calendar' );
return { events : entry.value ?? [], isStale : entry.isStale };
}),
regimeClassify : protectedProcedure.query ( async ({ ctx }) => {
const { classifyRegime } = await import ( '../macro/MacroRegime.ts' );
const input : Record < string , number > = {};
2026-07-23 18:02:24 -04:00
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 */ }
2026-07-05 08:50:12 -04:00
return classifyRegime ( input );
}),
2026-07-23 18:02:24 -04:00
commentary : publicProcedure.query ( async ({ ctx }) => {
2026-07-05 08:50:12 -04:00
const { classifyRegime , generateMacroCommentary } = await import ( '../macro/MacroRegime.ts' );
const input : Record < string , number > = {};
2026-07-23 18:02:24 -04:00
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 */ }
2026-07-05 08:50:12 -04:00
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 ({
2026-07-23 18:02:24 -04:00
// ---------------------------------------------------------------------------
// 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 () }))
2026-07-05 08:50:12 -04:00
. query ( async ({ ctx , input }) => {
2026-07-23 18:02:24 -04:00
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).
2026-07-05 08:50:12 -04:00
try {
2026-07-23 18:02:24 -04:00
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 };
2026-07-05 08:50:12 -04:00
} catch ( e ) {
2026-07-23 18:02:24 -04:00
throw new TRPCError ({ code : 'BAD_GATEWAY' , message : e instanceof Error ? e . message : 'X request failed.' });
2026-07-05 08:50:12 -04:00
}
}),
2026-07-23 18:02:24 -04:00
// Single trusted account timeline.
2026-07-05 08:50:12 -04:00
timeline : protectedProcedure
2026-07-23 18:02:24 -04:00
. input ( z . object ({ handle : z.string (). min ( 1 ). regex ( /^[a-zA-Z0-9_]+$/ ), limit : z.number (). min ( 5 ). max ( 200 ). optional (). default ( 20 ) }))
2026-07-05 08:50:12 -04:00
. query ( async ({ ctx , input }) => {
2026-07-23 18:02:24 -04:00
const creds = await loadXCredentials ( ctx );
if ( ! creds ) return { posts : [], configured : false as const };
2026-07-05 08:50:12 -04:00
const { XCookieAdapter } = await import ( '../adapters/XCookieAdapter.ts' );
2026-07-23 18:02:24 -04:00
const adapter = new XCookieAdapter ( creds , ( health : XCookieHealth ) => updateXHealth ( ctx . db , health . sourceStatus , health . lastError ?? undefined ));
2026-07-05 08:50:12 -04:00
try {
2026-07-23 18:02:24 -04:00
const posts = ( await adapter . trustedTimeline ( input . handle , {})). value ;
return { posts , configured : true as const };
2026-07-05 08:50:12 -04:00
} catch ( e ) {
2026-07-23 18:02:24 -04:00
throw new TRPCError ({ code : 'BAD_GATEWAY' , message : e instanceof Error ? e . message : 'X request failed.' });
2026-07-05 08:50:12 -04:00
}
}),
2026-07-23 18:02:24 -04:00
// 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 ?? []);
}),
2026-07-05 08:50:12 -04:00
});
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 {
2026-07-23 18:02:24 -04:00
const result = await adapter . subredditPosts ( input . subreddit , undefined , {});
2026-07-05 08:50:12 -04:00
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 {
2026-07-23 18:02:24 -04:00
const result = await adapter . searchPosts ( input . q , {});
2026-07-05 08:50:12 -04:00
return { posts : result.value , isStale : false };
} catch ( e ) {
throw new TRPCError ({ code : 'NOT_FOUND' , message : ( e as Error ). message });
}
}),
2026-07-23 18:02:24 -04:00
2026-07-05 08:50:12 -04:00
});
2026-07-23 18:02:24 -04:00
// Slice 8 — Institutional Dashboard Rollup (read-only, no trade actions).
// ─── Sizing Router (P0: wire-sizing-risk-trpc-m20) ────────────────────────────
// Pure SizingEngine behind tRPC. Outputs math + plain-English layers only (ADR-0007).
const sizingRouter = router ({
compute : protectedProcedure
. input ( z . object ({
symbol : z . string (). min ( 1 ). max ( 20 ),
tier : z.enum ([ 'A_STAR' , 'A' , 'B' , 'C' ]),
riskFraction : z.number (). positive (). max ( 0.25 ),
stopPerShare : z.number (). positive (),
equity : z.number (). positive (),
complexity : z.enum ([ 'beginner' , 'intermediate' , 'advanced' ]). optional (),
regime : z.enum ([ 'trending-up' , 'trending-down' , 'range-bound' ]). default ( 'range-bound' ),
aStarUnlocked : z.boolean (). optional (),
macroOverrideReason : z.string (). min ( 3 ). max ( 500 ). optional (),
holdings : z.array ( z . object ({
symbol : z . string (),
shares : z.number (),
avgCost : z.number (),
cluster : z.string (). default ( 'uncategorized' ),
})). optional (),
clusterCaps : z.record ( z . string (), z . number ()). nullable (). optional (),
}))
. query ( async ({ ctx , input }) => {
const { sizePosition } = await import ( '../sizing/SizingEngine.ts' );
const userId = ctx . userId as string ;
const user = ctx . db . prepare (
'SELECT complexity FROM users WHERE id=?' ,
). get ( userId ) as { complexity : string } | undefined ;
const complexity = ( input . complexity ?? user ? . complexity ?? 'beginner' ) as
'beginner' | 'intermediate' | 'advanced' ;
// Prefer live portfolio when client did not pass holdings.
let holdings = input . holdings ?? [];
if ( ! input . holdings ) {
const { listHoldings } = await import ( '../db/portfolioRepository.ts' );
holdings = listHoldings ( ctx . db , userId ). map (( h ) => ({
symbol : h . symbol ,
shares : h.shares ,
avgCost : h.avg_cost ,
cluster : 'uncategorized' ,
}));
}
const equity = input . equity ;
const defaultCap = complexity === 'beginner' ? equity * 0.25
: complexity === 'intermediate' ? equity * 0.40
: null ;
const clusterCaps = input . clusterCaps !== undefined
? input . clusterCaps
: ( defaultCap !== null ? { uncategorized : defaultCap } : null );
const unlockRow = ctx . db . prepare (
"SELECT 1 AS ok FROM sizing_unlocks WHERE user_id=? AND unlock='tier_a_star' LIMIT 1" ,
). get ( userId ) as { ok : number } | undefined ;
const aStarUnlocked = input . aStarUnlocked ?? !! unlockRow ;
// Gentle-halt: when active, risk fraction is educational-only but we still
// surface the halt flag so the UI can teach the circuit breaker.
const { isHalted } = await import ( '../risk/haltCircuitBreaker.ts' );
const halted = isHalted ( ctx . db , userId );
const result = sizePosition (
{
symbol : input . symbol . toUpperCase (),
tier : input.tier ,
riskFraction : halted ? Math . min ( input . riskFraction , 0.005 ) : input . riskFraction ,
stopPerShare : input.stopPerShare ,
},
{ equity , complexity },
{
holdings ,
regime : input.regime ,
clusterCaps ,
aStarUnlocked ,
},
{
aStarUnlocked ,
macroOverride : input.macroOverrideReason
? { reason : input.macroOverrideReason }
: null ,
},
);
return {
... result ,
halted ,
footer : 'Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions.' ,
};
}),
});
// ─── Risk Posture Router (P0: wire-sizing-risk-trpc-m20 / M20) ────────────────
const riskRouter = router ({
/** Full RiskPosture from portfolio + account equity inputs. */
posture : protectedProcedure
. input ( z . object ({
equity : z.number (). positive (),
peakEquity : z.number (). positive (). optional (),
drawdownTolerancePct : z.number (). positive (). max ( 100 ). optional (),
regime : z.enum ([ 'trending-up' , 'trending-down' , 'range-bound' ]). optional (),
/** Optional plan-level stops/targets keyed by symbol for asymmetry math. */
plans : z.array ( z . object ({
symbol : z . string (),
stopPrice : z.number (). optional (),
rewardTarget : z.number (). optional (),
cluster : z.string (). optional (),
})). optional (),
}))
. query ( async ({ ctx , input }) => {
const { assessRisk , ADR_0007_FOOTER } = await import ( '../risk/RiskEngine.ts' );
const { isHalted , getHaltRecord , triggerHalt } = await import ( '../risk/haltCircuitBreaker.ts' );
const { listHoldings } = await import ( '../db/portfolioRepository.ts' );
const userId = ctx . userId as string ;
const user = ctx . db . prepare (
'SELECT complexity, drawdown_tolerance FROM users WHERE id=?' ,
). get ( userId ) as { complexity : string ; drawdown_tolerance : number | null } | undefined ;
const complexity = ( user ? . complexity ?? 'beginner' ) as
'beginner' | 'intermediate' | 'advanced' ;
// Onboarding stores drawdown as a signed percent (e.g. -20); RiskEngine uses magnitude.
const drawdownTolerancePct = input . drawdownTolerancePct
?? ( typeof user ? . drawdown_tolerance === 'number'
? Math . abs ( user . drawdown_tolerance )
: 20 );
const planBySymbol = new Map (
( input . plans ?? []). map (( p ) => [ p . symbol . toUpperCase (), p ]),
);
const holdings = listHoldings ( ctx . db , userId );
const portfolio = holdings . map (( h ) => {
const plan = planBySymbol . get ( h . symbol . toUpperCase ());
return {
symbol : h . symbol ,
shares : h.shares ,
avgCost : h.avg_cost ,
cluster : plan?.cluster ?? 'uncategorized' ,
stopPrice : plan?.stopPrice ,
rewardTarget : plan?.rewardTarget ,
};
});
const { listOptionLegs } = await import ( '../db/portfolioOptionRepository.ts' );
const {
assessOptionRiskContribution ,
mergeOptionCapitalIntoClusters ,
} = await import ( '../risk/optionRiskContribution.ts' );
const optionLegs = listOptionLegs ( ctx . db , userId );
const equity = input . equity ;
const peakEquity = input . peakEquity ?? equity ;
const defaultCap = complexity === 'beginner' ? equity * 0.25
: complexity === 'intermediate' ? equity * 0.40
: null ;
const clusterCaps = defaultCap !== null ? { uncategorized : defaultCap } as Record < string , number > : null ;
const posture = assessRisk ({
portfolio ,
account : { equity , drawdownTolerancePct , complexity },
peakEquity ,
regime : input.regime ,
sizingContext : { clusterCaps },
});
const optionRisk = assessOptionRiskContribution (
optionLegs ,
holdings . map (( h ) => ({ symbol : h . symbol , shares : h.shares })),
equity ,
);
const clusterExposure = mergeOptionCapitalIntoClusters (
posture . clusterExposure ,
optionRisk . capitalByUnderlying ,
true ,
);
const recommendedActions = [
... posture . recommendedActions ,
... optionRisk . recommendations ,
];
// Persist gentle-halt when math says breached (24h cooldown).
if ( posture . halted && ! isHalted ( ctx . db , userId )) {
triggerHalt ( ctx . db , userId , 'max_drawdown_tolerance_breach' );
}
const haltRecord = getHaltRecord ( ctx . db , userId );
const haltActive = isHalted ( ctx . db , userId );
return {
... posture ,
clusterExposure ,
recommendedActions ,
halted : posture.halted || haltActive ,
halt : haltRecord && haltActive
? {
haltedUntil : haltRecord.halted_until ,
triggeredBy : haltRecord.triggered_by ,
ts : haltRecord.ts ,
}
: null ,
holdingsCount : holdings.length ,
totalExposureUsd : portfolio.reduce (( s , p ) => s + p . shares * p . avgCost , 0 ),
options : {
legsCount : optionRisk.legsCount ,
premiumAtRiskUsd : optionRisk.premiumAtRiskUsd ,
cashReservedUsd : optionRisk.cashReservedUsd ,
creditReceivedUsd : optionRisk.creditReceivedUsd ,
capitalCommittedUsd : optionRisk.capitalCommittedUsd ,
uncoveredShortCallCount : optionRisk.uncoveredShortCallCount ,
byUnderlying : optionRisk.byUnderlying ,
},
footer : ADR_0007_FOOTER ,
};
}),
haltStatus : protectedProcedure.query ( async ({ ctx }) => {
const { isHalted , getHaltRecord } = await import ( '../risk/haltCircuitBreaker.ts' );
const userId = ctx . userId as string ;
const active = isHalted ( ctx . db , userId );
const record = getHaltRecord ( ctx . db , userId );
return {
active ,
haltedUntil : active && record ? record.halted_until : null ,
triggeredBy : active && record ? record.triggered_by : null ,
};
}),
});
// ─── Emotion Logger Router (Slice: emotion-logger-storage) ──────────────────────
2026-07-05 08:50:12 -04:00
// 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-07-23 18:02:24 -04:00
sizing : sizingRouter , risk : riskRouter ,
2026-07-05 08:50:12 -04:00
});
2026-06-29 17:40:42 -04:00
export type AppRouter = typeof appRouter ;
2026-07-05 08:50:12 -04:00