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-08-10 13:36:26 -04:00
import {
STARTER_WATCHLIST ,
starterPackFor ,
resolveWorkspaceDefaults ,
defaultDrawdownTolerancePct ,
defaultRiskTolerance ,
ONBOARDING_DISCLAIMER ,
type Complexity ,
type Density ,
type ExperienceStage ,
type Goal ,
type Horizon ,
type JargonComfort ,
} from '../onboarding/starter.ts' ;
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 { XCookieHealth } from '../adapters/XCookieAdapter.ts' ;
import { emaFromCandles , rsi as rsiFn , relativeVolume , macd as macdFn } from '../analysis/indicators.ts' ;
2026-08-10 13:36:26 -04:00
import { listUsers , resetPassword , gdprExport , queueHealth , alertStatus , resetQueueBackoff , NotOwnerError , listUserSessions , listAuditLog , queueSecFetch , setUserModules , disableUser , enableUser , deleteUser , getFinraBaseUrl , setFinraBaseUrl , recordAudit } 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-08-10 13:36:26 -04:00
import { computeOwnershipPercentages , fetchAndPersistFloat } from '../services/stockFloatService.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-08-10 16:07:19 -04:00
import { CONFLUENCE_SLOTS , slotsByFamily , type SlotFamily } from '../confluence/confluenceSlots.ts' ;
import { CONFLUENCE_SLOT_IDS } from '../confluence/confluenceSlots.ts' ;
import { ConfluenceRepository , rackFromSlots } from '../db/confluenceRepository.ts' ;
import { runSlotBacktest , signalHistoryToStats , type ConfluenceFireEvent } from '../confluence/confluenceBacktest.ts' ;
import { detectPictureChange } from '../confluence/confluenceRack.ts' ;
2026-08-11 22:25:17 -04:00
import { runCorridorBacktest , aggregateBacktest } from '../analysis/corridorBacktest.ts' ;
import { runConfluenceEvaluationCycle } from '../confluence/confluenceEngine.ts' ;
import { CorridorRepository } from '../db/corridorRepository.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-08-10 13:36:26 -04:00
const u = ctx . db . prepare (
'SELECT id,email,complexity,risk_tolerance,convexity_posture,modules,experience_stage,goal,horizon,density,jargon_comfort FROM users WHERE id=?' ,
). get ( ctx . userId ) as {
id : string ;
email : string ;
complexity : string ;
risk_tolerance : string ;
convexity_posture : string ;
modules : string ;
experience_stage : string | null ;
goal : string | null ;
horizon : string | null ;
density : string | null ;
jargon_comfort : string | null ;
} | undefined ;
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' );
}
2026-08-10 13:36:26 -04:00
const density = ( u ? . density === 'standard' || u ? . density === 'full' || u ? . density === 'focused' )
? u . density
: ( u ? . complexity === 'advanced' ? 'full' : u ? . complexity === 'intermediate' ? 'standard' : 'focused' );
return u ? {
userId : u.id ,
email : u.email ,
complexity : u.complexity ,
riskTolerance : u.risk_tolerance ,
convexityPosture : u.convexity_posture ,
onboarded : !! wl ,
modules ,
experienceStage : u.experience_stage ,
goal : u.goal ,
horizon : u.horizon ,
density ,
jargonComfort : u.jargon_comfort ?? 'plain' ,
} : null ;
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 ({
2026-08-10 13:36:26 -04:00
// Public: starter symbols for a density (default focused). Used in workspace setup preview.
starter : publicProcedure
. input ( z . object ({
density : z.enum ([ 'focused' , 'standard' , 'full' ]). optional (),
experienceStage : z.enum ([ 'never_invested' , 'some_stocks' , 'active_self_directed' ]). optional (),
}). optional ())
. query (({ input }) => {
const density : Density = input ? . density
?? ( input ? . experienceStage === 'active_self_directed'
? 'full'
: input ? . experienceStage === 'some_stocks'
? 'standard'
: 'focused' );
const pack = starterPackFor ( density );
return { watchlist : pack , density , disclaimer : ONBOARDING_DISCLAIMER };
}),
// Protected: interview → workspace profile + first watchlist + optional portfolio.
2026-06-29 21:31:59 -04:00
complete : protectedProcedure
. input ( z . object ({
2026-08-10 13:36:26 -04:00
// Preferred interview path
experienceStage : z.enum ([ 'never_invested' , 'some_stocks' , 'active_self_directed' ]). optional (),
goal : z.enum ([ 'grow' , 'income' , 'protect' ]). optional (),
horizon : z.enum ([ 'short' , 'medium' , 'long' ]). optional (),
density : z.enum ([ 'focused' , 'standard' , 'full' ]). optional (),
jargonComfort : z.enum ([ 'plain' , 'mixed' , 'technical' ]). optional (),
// Legacy path (still accepted)
complexity : z.enum ([ 'beginner' , 'intermediate' , 'advanced' ]). optional (),
2026-06-29 21:31:59 -04:00
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 ;
2026-08-10 13:36:26 -04:00
const experienceStage : ExperienceStage = input . experienceStage
?? ( input . complexity === 'advanced'
? 'active_self_directed'
: input . complexity === 'intermediate'
? 'some_stocks'
: 'never_invested' );
const defaults = resolveWorkspaceDefaults ({
experienceStage ,
goal : input.goal as Goal | undefined ,
horizon : input.horizon as Horizon | undefined ,
density : input.density as Density | undefined ,
jargonComfort : input.jargonComfort as JargonComfort | undefined ,
});
const complexity = ( input . complexity ?? defaults . complexity ) as Complexity ;
const riskTolerance = input . riskTolerance ?? defaults . riskTolerance ;
const drawdown = input . drawdownTolerancePct ?? defaults . drawdownTolerancePct ;
ctx . db . prepare (
`UPDATE users SET complexity=?, risk_tolerance=?, drawdown_tolerance=?,
experience_stage=?, goal=?, horizon=?, density=?, jargon_comfort=? WHERE id=?` ,
). run (
complexity ,
riskTolerance ,
drawdown ,
defaults . experienceStage ,
defaults . goal ,
defaults . horizon ,
defaults . density ,
defaults . jargonComfort ,
userId ,
);
const pack = defaults . starterPack ;
const symbols = input . firstWatchlistSymbols ?? pack . map (( s ) => s . symbol );
const existingWl = ctx . db . prepare ( 'SELECT id FROM watchlists WHERE owner_id=? LIMIT 1' ). get ( userId ) as { id : string } | undefined ;
let wlId = existingWl ? . id ;
if ( ! wlId ) {
wlId = randomUUID ();
ctx . db . prepare ( 'INSERT INTO watchlists (id, owner_id, name, symbols, created_at, sort_order) VALUES (?,?,?,?,?,?)' ). run (
wlId , userId , 'default' , JSON . stringify ( symbols ), new Date (). toISOString (), 0 ,
);
}
const allKnown = [... STARTER_WATCHLIST , ... starterPackFor ( 'full' )];
2026-06-29 21:31:59 -04:00
for ( const sym of symbols ) {
2026-08-10 13:36:26 -04:00
const kind = ( allKnown . find (( s ) => s . symbol === sym ) ? . tickerKind
?? pack . find (( s ) => s . symbol === sym ) ? . tickerKind
?? 'equity' ) as 'equity' | 'crypto' | 'etf' | 'index' ;
try { await ctx . cache . subscribe ( sym , kind ); } catch { /* ignore */ }
try { queueSecFetch ( ctx . db , sym ); } catch { /* ignore */ }
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 );
2026-08-10 13:36:26 -04:00
} catch { /* ignore — sector data not available yet */ }
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' );
}
2026-08-10 13:36:26 -04:00
return {
ok : true ,
watchlistId : wlId ,
density : defaults.density ,
complexity ,
experienceStage : defaults.experienceStage ,
};
}),
// Protected: update workspace profile after onboarding (density, goal, jargon) without re-seeding watchlist.
updateProfile : protectedProcedure
. input ( z . object ({
experienceStage : z.enum ([ 'never_invested' , 'some_stocks' , 'active_self_directed' ]). optional (),
goal : z.enum ([ 'grow' , 'income' , 'protect' ]). optional (),
horizon : z.enum ([ 'short' , 'medium' , 'long' ]). optional (),
density : z.enum ([ 'focused' , 'standard' , 'full' ]). optional (),
jargonComfort : z.enum ([ 'plain' , 'mixed' , 'technical' ]). optional (),
}))
. mutation (({ ctx , input }) => {
const userId = ctx . userId as string ;
const row = ctx . db . prepare (
'SELECT experience_stage, goal, horizon, density, jargon_comfort, complexity FROM users WHERE id=?' ,
). get ( userId ) as {
experience_stage : string | null ;
goal : string | null ;
horizon : string | null ;
density : string | null ;
jargon_comfort : string | null ;
complexity : string ;
} | undefined ;
if ( ! row ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'User not found.' });
const experienceStage = ( input . experienceStage
?? row . experience_stage
?? 'never_invested' ) as ExperienceStage ;
const density = ( input . density ?? row . density ?? 'focused' ) as Density ;
const goal = ( input . goal ?? row . goal ?? 'grow' ) as Goal ;
const horizon = ( input . horizon ?? row . horizon ?? 'long' ) as Horizon ;
const jargonComfort = ( input . jargonComfort ?? row . jargon_comfort ?? 'plain' ) as JargonComfort ;
const complexity = complexityFromExperienceSafe ( experienceStage );
ctx . db . prepare (
`UPDATE users SET experience_stage=?, goal=?, horizon=?, density=?, jargon_comfort=?, complexity=? WHERE id=?` ,
). run ( experienceStage , goal , horizon , density , jargonComfort , complexity , userId );
return { ok : true , experienceStage , goal , horizon , density , jargonComfort , complexity };
2026-06-29 21:31:59 -04:00
}),
});
2026-08-10 13:36:26 -04:00
function complexityFromExperienceSafe ( stage : ExperienceStage ) : Complexity {
if ( stage === 'active_self_directed' ) return 'advanced' ;
if ( stage === 'some_stocks' ) return 'intermediate' ;
return 'beginner' ;
}
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
2026-08-10 13:36:26 -04:00
/**
* Batched snapshot for multiple symbols — reduces N+1 API calls.
* Used by portfolio/watchlist views that need quotes for many symbols at once.
*/
snapshots : publicProcedure
. input ( z . object ({ symbols : z.array ( z . string (). min ( 1 )). max ( 50 ) }))
. query ( async ({ ctx , input }) => {
const symbols = input . symbols . map (( s ) => s . toUpperCase ());
// Build all cache keys at once
const keys = symbols . flatMap (( symbol ) => [
`yfinance:quote: ${ symbol } ` ,
`yfinance:candles: ${ symbol } :1d` ,
`yfinance:symbol: ${ symbol } ` ,
]);
// Batch read all at once
const entries = await ctx . cache . getMany < unknown >( keys );
const byKey = new Map ( entries . map (( e ) => [ e . key , e ]));
// Group results by symbol
const results : Array < {
symbol : string ;
quote : Quote | null ;
candles : PriceCandle [] | null ;
sector : SymbolMeta | null ;
stale : { quote : boolean ; candles : boolean ; sector : boolean };
} > = [];
for ( const symbol of symbols ) {
const kQuote = `yfinance:quote: ${ symbol } ` ;
const kCandles = `yfinance:candles: ${ symbol } :1d` ;
const kSector = `yfinance:symbol: ${ symbol } ` ;
results . push ({
symbol ,
quote : byKey.get ( kQuote ) ? . value as Quote | null ?? null ,
candles : byKey.get ( kCandles ) ? . value as PriceCandle [] | null ?? null ,
sector : byKey.get ( kSector ) ? . value as SymbolMeta | null ?? null ,
stale : {
quote : byKey.get ( kQuote ) ? . isStale ?? true ,
candles : byKey.get ( kCandles ) ? . isStale ?? true ,
sector : byKey.get ( kSector ) ? . isStale ?? true ,
},
});
}
return results ;
}),
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 ();
2026-08-11 22:25:17 -04:00
try { await ctx . cache . bumpToWatched ( symbol , 'equity' ); } catch { /* ignore */ }
try { await ctx . cache . bumpToWatched ( BENCHMARK_SYMBOL , 'etf' ); } catch { /* ignore */ }
2026-07-23 18:02:24 -04:00
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 ;
2026-08-10 13:36:26 -04:00
// Peers: curated override first, then vendor peers, then sector-ETF holdings cache (never live Yahoo on request path).
2026-07-23 18:02:24 -04:00
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 {
2026-08-10 13:36:26 -04:00
const holdEntry = await ctx . cache . get < { holdings? : Array < { symbol ?: string } > } > (
`yfinance:topHoldings: ${ sectorMap . etf } ` ,
);
const holdings = holdEntry . value ? . holdings ?? [];
peerSymbols = holdings
2026-07-23 18:02:24 -04:00
. map (( h ) => ( h . symbol ?? '' ). toUpperCase ())
. filter (( p ) => p && p !== symbol )
. slice ( 0 , 8 );
2026-08-10 13:36:26 -04:00
// Also try kv_cache layout used by sectorHoldings
if ( peerSymbols . length === 0 ) {
const row = ctx . db . prepare ( 'SELECT value FROM kv_cache WHERE key=?' ). get ( `etf:topHoldings: ${ sectorMap . etf } ` ) as
| { value : string }
| undefined ;
if ( row ? . value ) {
const parsed = JSON . parse ( row . value ) as { holdings? : Array < { symbol ?: string } > };
peerSymbols = ( parsed . holdings ?? [])
. map (( h ) => ( h . symbol ?? '' ). toUpperCase ())
. filter (( p ) => p && p !== symbol )
. slice ( 0 , 8 );
}
}
} catch { /* cache peer pull optional */ }
2026-07-23 18:02:24 -04:00
}
const symbolsToLoad = Array . from ( new Set ([
symbol ,
BENCHMARK_SYMBOL ,
...( sectorMap ? [ sectorMap . etf ] : []),
...( themeMap ? [ themeMap . etf ] : []),
... peerSymbols . slice ( 0 , 8 ),
]));
for ( const s of symbolsToLoad ) {
2026-08-10 13:36:26 -04:00
const kind = s === BENCHMARK_SYMBOL || MARKET_ROTATION_UNIVERSE . some (( u ) => u . symbol === s ) ? 'etf' : 'equity' ;
2026-08-11 22:25:17 -04:00
try { await ctx . cache . bumpToWatched ( s , kind ); } catch { /* ignore */ }
2026-07-23 18:02:24 -04:00
}
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.
2026-08-10 13:36:26 -04:00
* FRED series are queue-warmed (fred schedule, fred_macro tier) — never fetched
* live on this request path. SPY/VIX from yfinance cache only.
2026-07-23 18:02:24 -04:00
* Educational snapshot only (ADR-0007).
*/
condition : publicProcedure.query ( async ({ ctx }) => {
const { classifyRegime } = await import ( '../macro/MacroRegime.ts' );
const { totalReturnPct } = await import ( '../analysis/marketRotationRs.ts' );
2026-08-10 13:36:26 -04:00
const { FRED_SERIES } = await import ( '../macro/FredAdapter.ts' );
2026-07-23 18:02:24 -04:00
const factors : Record < string , number | null > = {
gdpGrowth : null ,
cpi : null ,
unemployment : null ,
treasury10Y : null ,
treasury2Y : null ,
curve10y2y : null ,
vix : null ,
spy1M : null ,
spyEmaSlope : null ,
2026-08-10 13:36:26 -04:00
consumerSentiment : null ,
2026-07-23 18:02:24 -04:00
};
type FredObs = Array < { date : string ; value : number } > ;
2026-08-10 13:36:26 -04:00
/**
* FRED observations: kv_cache only (queue warms it). If cold/stale, queue a
* background warm-up and return null — never open a live FRED call here.
*/
2026-07-23 18:02:24 -04:00
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 */ }
2026-08-10 13:36:26 -04:00
// Cold or stale: queue warm-up for the scheduler (no live fetch on request path).
try { await ctx . queue . queue ( cacheKey ); } catch { /* ignore */ }
return null ;
2026-07-23 18:02:24 -04:00
}
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 ;
}
2026-08-10 13:36:26 -04:00
// Parallel FRED pulls (rates, labor, inflation, consumer sentiment).
const [ unrateObs , gs10Obs , gs2Obs , cpiObs , umcObs ] = await Promise . all ([
2026-07-23 18:02:24 -04:00
fredObs ( FRED_SERIES . UNEMPLOYMENT ),
fredObs ( FRED_SERIES . TREASURY_10Y ),
fredObs ( FRED_SERIES . TREASURY_2Y ),
fredObs ( FRED_SERIES . CPI ),
2026-08-10 13:36:26 -04:00
fredObs ( FRED_SERIES . CONSUMER_SENTIMENT ),
2026-07-23 18:02:24 -04:00
]);
factors . unemployment = lastValue ( unrateObs );
factors . treasury10Y = lastValue ( gs10Obs );
factors . treasury2Y = lastValue ( gs2Obs );
factors . cpi = yoyPct ( cpiObs ); // classifier expects inflation %, not CPI index level
2026-08-10 13:36:26 -04:00
factors . consumerSentiment = lastValue ( umcObs ); // UMCSENT: University of Michigan Consumer Sentiment Index (index level, not YoY)
2026-07-23 18:02:24 -04:00
if ( factors . treasury10Y !== null && factors . treasury2Y !== null ) {
factors . curve10y2y = factors . treasury10Y - factors . treasury2Y ;
}
2026-08-10 13:36:26 -04:00
// SPY trend + VIX from cache/queue only (ADR-0009: no live Yahoo on request path).
let spyCandles : PriceCandle [] = [];
2026-07-23 18:02:24 -04:00
try {
2026-08-11 22:25:17 -04:00
try { await ctx . cache . bumpToWatched ( 'SPY' , 'etf' ); } catch { /* ignore */ }
try { await ctx . cache . bumpToWatched ( '^VIX' , 'index' ); } catch { /* ignore */ }
2026-07-23 18:02:24 -04:00
const spyEntry = await ctx . cache . get < PriceCandle [] >( 'yfinance:candles:SPY:1d' );
2026-08-10 13:36:26 -04:00
spyCandles = ( spyEntry ? . value ?? []) as PriceCandle [];
factors . spy1M = totalReturnPct ( spyCandles , 30 * 86 _400_000 );
if ( spyCandles . length >= 60 ) {
const last = spyCandles [ spyCandles . length - 1 ]. c ;
const ago = spyCandles [ spyCandles . length - 50 ]. c ;
2026-07-23 18:02:24 -04:00
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 ;
}
2026-08-10 13:36:26 -04:00
// Queue VIX quote if still cold — never open a live Yahoo call here (shared cool-down).
2026-07-23 18:02:24 -04:00
if ( factors . vix == null ) {
2026-08-10 13:36:26 -04:00
try { await ctx . queue . queue ( 'yfinance:quote:^VIX' ); } catch { /* ignore */ }
2026-07-23 18:02:24 -04:00
}
} catch { /* ignore */ }
2026-08-10 13:36:26 -04:00
// Per-metric context strings (hover tooltips explaining what each value means).
function trailingAvg ( obs : Array < { value : number } > , n : number ) : number | null {
if ( ! obs || obs . length < n ) return null ;
const recent = obs . slice ( - n );
let sum = 0 ;
for ( const o of recent ) sum += o . value ;
return sum / n ;
2026-07-23 18:02:24 -04:00
}
2026-08-10 13:36:26 -04:00
const metricCtx : Record < string , string > = {};
// SPY 1M — current vs trailing ~6mo average return.
if ( factors . spy1M != null && spyCandles . length >= 126 ) {
const recent = spyCandles . slice ( - 127 , - 1 ); // last ~5 months minus today
if ( recent . length >= 2 ) {
const firstPrice = recent [ 0 ]. c ;
const lastPrice = recent [ recent . length - 1 ]. c ;
if ( firstPrice > 0 ) {
const avgReturn = (( lastPrice - firstPrice ) / firstPrice ) * 100 ;
const label = factors . spy1M > avgReturn ? 'above trend' : 'below trend' ;
metricCtx . spy1M = label ;
}
}
}
// VIX — regime-based interpretation (no advice language).
if ( factors . vix != null ) {
const v = factors . vix ;
if ( v < 15 ) metricCtx . vix = 'Low volatility — risk-on regime' ;
else if ( v < 20 ) metricCtx . vix = 'Normal range' ;
else if ( v < 30 ) metricCtx . vix = 'Elevated — historically precedes drawdowns' ;
else metricCtx . vix = 'Stressed — risk-off / crisis territory' ;
}
// Treasury 10Y vs rolling ~3mo average.
if ( factors . treasury10Y != null && gs10Obs ? . length ) {
const last = factors . treasury10Y ;
const avg3m = trailingAvg ( gs10Obs , Math . min ( 63 , gs10Obs . length ));
if ( avg3m != null ) {
const diff = last - avg3m ;
metricCtx . treasury10Y = diff > 0
? 'Above 3-month average — headwind for growth if elevated'
: 'Below 3-month average' ;
} else {
metricCtx . treasury10Y = 'Headwind for growth if elevated' ;
}
}
// Treasury 2Y vs rolling ~3mo average.
if ( factors . treasury2Y != null && gs2Obs ? . length ) {
const last = factors . treasury2Y ;
const avg3m = trailingAvg ( gs2Obs , Math . min ( 63 , gs2Obs . length ));
if ( avg3m != null ) {
const diff = last - avg3m ;
metricCtx . treasury2Y = diff > 0
? 'Above 3-month average — tighter monetary conditions'
: 'Below 3-month average' ;
} else {
metricCtx . treasury2Y = 'Tight monetary conditions' ;
}
}
// Curve sign + magnitude (50bp threshold for inversion signal).
if ( factors . curve10y2y != null ) {
const cBp = factors . curve10y2y * 100 ;
if ( cBp < - 50 ) metricCtx . curve10y2y = 'Inverted — historically bearish for equities' ;
else if ( cBp < 0 ) metricCtx . curve10y2y = 'Slightly inverted — watch for steepening' ;
else if ( cBp < 50 ) metricCtx . curve10y2y = 'Positive spread — normal cycle' ;
else metricCtx . curve10y2y = 'Steep — recovery or inflation expectations rising' ;
}
// Unemployment vs ~5% recession threshold.
if ( factors . unemployment != null ) {
const u = factors . unemployment ;
if ( u < 4.0 ) metricCtx . unemployment = 'Strong labor market' ;
else if ( u < 5.0 ) metricCtx . unemployment = 'Stable — below recession threshold of 5%' ;
else if ( u < 6.0 ) metricCtx . unemployment = 'Elevated — monitor for further rises' ;
else metricCtx . unemployment = 'High — historically correlates with recessions' ;
}
// Consumer sentiment vs UMCSENT psychological thresholds (80/100).
if ( factors . consumerSentiment != null ) {
const s = factors . consumerSentiment ;
if ( s > 100 ) metricCtx . consumerSentiment = 'Strong confidence' ;
else if ( s > 80 ) metricCtx . consumerSentiment = 'Moderate sentiment' ;
else if ( s > 60 ) metricCtx . consumerSentiment = 'Weak — below 80 historically precedes drawdowns' ;
else metricCtx . consumerSentiment = 'Very low confidence — crisis territory' ;
}
// Compute regime + risk flags using raw factors.
2026-07-23 18:02:24 -04:00
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 ,
2026-08-10 13:36:26 -04:00
consumerSentiment : factors.consumerSentiment ,
context : metricCtx ,
2026-07-23 18:02:24 -04:00
},
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 ) {
2026-08-11 22:25:17 -04:00
try { await ctx . cache . bumpToWatched ( sym , 'etf' ); } catch { /* ignore */ }
2026-07-23 18:02:24 -04:00
}
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 ,
2026-08-10 13:36:26 -04:00
`Relative leadership may be shifting ( ${ signal } ). Leading: ${ summary . leadingGroup } . Lagging: ${ summary . laggingGroup } . Spread ~ ${ spread . toFixed ( 1 ) } pp vs SPY.` ,
2026-07-23 18:02:24 -04:00
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' );
2026-08-10 13:36:26 -04:00
const { resolveBusinessContext } = await import ( '../analysis/tickerContext.ts' );
const { listHoldings } = await import ( '../db/portfolioRepository.ts' );
2026-07-23 18:02:24 -04:00
2026-08-10 13:36:26 -04:00
const userId = ctx . userId ?? 'anonymous' ;
// Load custom symbols from DB
const customRows = ctx . db . prepare (
'SELECT symbol, name, grp FROM rotation_custom_symbols WHERE owner_id=? ORDER BY symbol' ,
). all ( userId ) as Array < { symbol : string ; name : string | null ; grp : string } > ;
const customDefs : import ( '../analysis/marketRotationRs.ts' ). SectorDef [] = customRows . map (( r ) => ({
symbol : r . symbol ,
name : r.name ?? r . symbol ,
group : r.grp ,
kind : 'custom' as const ,
}));
// Auto-group: ETFs that open portfolio holdings map into (sector + theme).
// Many of these already exist in MARKET_ROTATION_UNIVERSE (XLK, XLF, …). We must not
// drop them on dedupe — track portfolio linkage separately so the Auto filter works.
const holdings = listHoldings ( ctx . db , userId );
type AutoMeta = { name : string ; group : string ; holdings : string [] };
const portfolioEtfMeta = new Map < string , AutoMeta >();
const addPortfolioEtf = ( etf : string , name : string , group : string , holdingSym : string ) => {
const key = etf . toUpperCase ();
const prev = portfolioEtfMeta . get ( key );
if ( prev ) {
if ( ! prev . holdings . includes ( holdingSym )) prev . holdings . push ( holdingSym );
return ;
}
portfolioEtfMeta . set ( key , { name , group , holdings : [ holdingSym ] });
};
for ( const h of holdings ) {
const holdingSym = h . symbol . toUpperCase ();
const meta = ctx . db . prepare ( 'SELECT sector, industry FROM symbols WHERE symbol=?' ). get ( holdingSym ) as
| { sector : string | null ; industry : string | null }
| undefined ;
// Always resolve — symbol overrides (IREN, CIFR, …) work even without a symbols row.
const biz = resolveBusinessContext ({
symbol : holdingSym ,
sector : meta?.sector ?? null ,
industry : meta?.industry ?? null ,
description : null ,
});
if ( biz . sectorEtf ) {
addPortfolioEtf (
biz . sectorEtf ,
biz . sectorLabel ?? biz . sectorEtf ,
biz . sectorLabel ?? 'Portfolio' ,
holdingSym ,
);
}
if ( biz . themeEtf ) {
addPortfolioEtf (
biz . themeEtf ,
biz . themeLabel ?? biz . themeEtf ,
biz . themeLabel ?? 'Portfolio theme' ,
holdingSym ,
);
}
}
// Portfolio-only ETFs (e.g. IGV) that are not already in the market universe
const autoOnlyDefs : import ( '../analysis/marketRotationRs.ts' ). SectorDef [] = [];
for ( const [ etf , meta ] of portfolioEtfMeta ) {
const inUniverse = MARKET_ROTATION_UNIVERSE . some (( u ) => u . symbol === etf )
|| customDefs . some (( c ) => c . symbol === etf );
if ( ! inUniverse ) {
autoOnlyDefs . push ({
symbol : etf ,
name : meta.name ,
group : meta.group ,
kind : 'auto' ,
});
}
}
// Combine and deduplicate (universe first so sector/style kinds stay stable)
const allDefs = [... MARKET_ROTATION_UNIVERSE , ... customDefs , ... autoOnlyDefs ];
const seen = new Set < string >();
const dedupedDefs = allDefs . filter (( d ) => {
if ( seen . has ( d . symbol )) return false ;
seen . add ( d . symbol );
return true ;
});
const symbols = [ BENCHMARK_SYMBOL , ... dedupedDefs . map (( s ) => s . symbol )];
2026-07-23 18:02:24 -04:00
for ( const sym of symbols ) {
2026-08-11 22:25:17 -04:00
try { await ctx . cache . bumpToWatched ( sym , 'etf' ); } catch { /* ignore */ }
2026-07-23 18:02:24 -04:00
}
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 ;
});
2026-08-10 13:36:26 -04:00
// Load live quotes for oneDay overrides — when available, use intraday
// change_pct (current price vs previous close) instead of candle dayChangePct,
// which reflects overnight gaps because yfinance 1d candles carry 13:30Z bar
// timestamps. Other horizons stay candle-based.
const quoteRows = ctx . db . prepare (
`SELECT symbol, change_pct FROM quotes WHERE symbol IN ( ${ symbols . map (() => '?' ). join ( ',' ) } )` ,
). all (... symbols ) as Array < { symbol : string ; change_pct : number | null } > ;
const liveQuotes = new Map < string , number >( quoteRows . map (( r ) => [ r . symbol , r . change_pct ?? null ]));
2026-07-23 18:02:24 -04:00
const rows = buildSectorRsMap (
2026-08-10 13:36:26 -04:00
dedupedDefs ,
2026-07-23 18:02:24 -04:00
map ,
map [ BENCHMARK_SYMBOL ] ?? [],
);
2026-08-10 13:36:26 -04:00
// Override oneDay with live quote change_pct when available (true intraday vs prev close).
for ( const r of rows ) {
const qPct = liveQuotes . get ( r . symbol );
if ( qPct != null && Number . isFinite ( qPct )) { /* already in percent units from yfinance */
r . abs . oneDay = qPct ;
const benchPct = liveQuotes . get ( BENCHMARK_SYMBOL );
r . rs . oneDay = benchPct != null ? qPct - benchPct : qPct ;
}
}
2026-07-23 18:02:24 -04:00
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).
2026-08-10 13:36:26 -04:00
// portfolioLinked: true when this ETF is a sector/theme proxy for an open holding.
// Auto filter uses portfolioLinked so XLK/XLF/etc. still appear under Auto even though
// they also live in the main GICS universe (kind remains sector/thematic/…).
const sectors = rows . map (( r ) => {
const port = portfolioEtfMeta . get ( r . symbol );
// Keep universe display names (Technology, Financials); use portfolio label only for auto-only ETFs.
const displayName = r . kind === 'auto' && port ? . name ? port.name : r.name ;
return {
symbol : r . symbol ,
name : displayName ,
group : r.leadership === 'leading' ? 'inflow' as const
: r . leadership === 'lagging' ? 'outflow' as const
: 'inflow' as const , // inline treated as neutral; UI will use leadership field
subGroup : r.group ,
kind : r.kind ,
portfolioLinked : !! port ,
portfolioHoldings : port?.holdings ?? [],
leadership : r.leadership ,
earlyWatch : r.earlyWatch ,
rank1M : r.rank1M ,
rank1W : r.rank1W ,
relVol : r.relVol ,
oneWeek : r.abs.oneWeek ,
oneMonth : r.abs.oneMonth ,
threeMonth : r.abs.threeMonth ,
sixMonth : r.abs.sixMonth ,
oneYear : r.abs.oneYear ,
oneDay : r.abs.oneDay ,
rsOneWeek : r.rs.oneWeek ,
rsOneMonth : r.rs.oneMonth ,
rsThreeMonth : r.rs.threeMonth ,
rsSixMonth : r.rs.sixMonth ,
rsOneYear : r.rs.oneYear ,
rsOneDay : r.rs.oneDay ,
};
});
2026-07-23 18:02:24 -04:00
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 ();
2026-08-11 22:25:17 -04:00
try { await ctx . cache . bumpToWatched ( symbol , symbol === 'SPY' ? 'etf' : 'equity' ); } catch { /* ignore */ }
2026-07-23 18:02:24 -04:00
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 ) {
2026-08-10 13:36:26 -04:00
// Stale composition is fine for a peek panel; background queue refreshes.
2026-07-23 18:02:24 -04:00
holdings = cached . rows ;
source = 'cache' ;
2026-08-10 13:36:26 -04:00
if ( ! ctx . queue . isSourceCoolingDown ( 'yfinance' )) {
try { await ctx . queue . queue ( `yfinance:topHoldings: ${ symbol } ` ); } catch { /* ignore */ }
}
2026-07-23 18:02:24 -04:00
} else {
2026-08-10 13:36:26 -04:00
// Cold path: static fallback first (authoritative when present).
2026-07-23 18:02:24 -04:00
const fallback = fromStatic ();
if ( fallback . length > 0 ) {
holdings = fallback ;
source = 'static' ;
writeCachedComposition ( fallback );
}
2026-08-10 13:36:26 -04:00
// Queue background upgrade — never live Yahoo on the request path (ADR-0009).
if ( ! ctx . queue . isSourceCoolingDown ( 'yfinance' )) {
try { await ctx . queue . queue ( `yfinance:topHoldings: ${ symbol } ` ); } catch { /* ignore */ }
2026-07-23 18:02:24 -04:00
}
}
if ( holdings . length === 0 ) {
return { symbol , holdings : [], source : 'empty' as const };
}
2026-08-10 13:36:26 -04:00
// Cap at top 15 for the peek panel (matches UI) and for quote fan-in.
const top = holdings . slice ( 0 , 15 );
const symbols = top . map (( h ) => h . symbol . toUpperCase ());
2026-07-23 18:02:24 -04:00
2026-08-10 13:36:26 -04:00
// Local quotes first — never open live Yahoo on the request path (ADR-0009).
2026-07-23 18:02:24 -04:00
const quoteMap = new Map < string , {
price : number | null ;
change : number | null ;
changePercent : number | null ;
}>();
try {
const placeholders = symbols . map (() => '?' ). join ( ',' );
const rows = ctx . db . prepare (
`SELECT symbol, price, change, change_pct FROM quotes WHERE symbol IN ( ${ placeholders } )` ,
). all (... symbols ) as Array < {
symbol : string ;
price : number | null ;
change : number | null ;
change_pct : number | null ;
} > ;
for ( const r of rows ) {
quoteMap . set ( r . symbol . toUpperCase (), {
price : r.price ,
change : r.change ,
changePercent : r.change_pct ,
});
}
} catch { /* local quotes optional */ }
2026-08-10 13:36:26 -04:00
// Candle close fallback when quote row is missing (still local, no vendor call).
const candleClose = new Map < string , number >();
try {
for ( const sym of symbols ) {
if ( quoteMap . get ( sym ) ? . price != null ) continue ;
const row = ctx . db . prepare (
`SELECT c FROM price_candles WHERE symbol=? AND timeframe='1d' ORDER BY ts DESC LIMIT 1` ,
). get ( sym ) as { c : number } | undefined ;
if ( row && Number . isFinite ( row . c )) candleClose . set ( sym , row . c );
}
} catch { /* ignore */ }
// Background-queue missing US-style tickers so the next open has live marks.
// Skip exotic listings (dots / long suffixes) to avoid poisoning the queue.
if ( ! ctx . queue . isSourceCoolingDown ( 'yfinance' )) {
for ( const sym of symbols ) {
if ( quoteMap . has ( sym ) && quoteMap . get ( sym ) ! . price != null ) continue ;
if ( sym . includes ( '.' ) || sym . length > 5 ) continue ;
2026-08-11 22:25:17 -04:00
try { await ctx . cache . bumpToWatched ( sym , 'equity' ); } catch { /* ignore */ }
2026-08-10 13:36:26 -04:00
}
}
const result = top . map (( h ) => {
2026-07-23 18:02:24 -04:00
const sym = h . symbol . toUpperCase ();
const q = quoteMap . get ( sym );
2026-08-10 13:36:26 -04:00
const price = q ? . price ?? candleClose . get ( sym ) ?? null ;
2026-07-23 18:02:24 -04:00
return {
symbol : sym ,
name : h.holdingName ,
weight : h.holdingPercent ,
2026-08-10 13:36:26 -04:00
price ,
2026-07-23 18:02:24 -04:00
change : q?.change ?? null ,
changePercent : q?.changePercent ?? null ,
2026-08-10 13:36:26 -04:00
volume : null as number | null ,
marketCap : null as number | null ,
2026-07-23 18:02:24 -04:00
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-08-10 13:36:26 -04:00
/** Add a custom ETF to the user's rotation tracking. */
addCustomEtf : publicProcedure
. input ( z . object ({ symbol : z . string (). toUpperCase (), name : z.string (). optional (), group : z.string (). optional () }))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId ?? 'anonymous' ;
const existing = ctx . db . prepare ( 'SELECT id FROM rotation_custom_symbols WHERE owner_id=? AND symbol=?' ). get ( userId , input . symbol ) as { id : string } | undefined ;
if ( existing ) return { added : false };
ctx . db . prepare (
'INSERT INTO rotation_custom_symbols (id, owner_id, symbol, name, grp, created_at) VALUES (?,?,?,?,?,?)'
). run ( crypto . randomUUID (), userId , input . symbol , input . name ?? input . symbol , input . group ?? 'Custom' , new Date (). toISOString ());
2026-08-11 22:25:17 -04:00
try { await ctx . cache . bumpToWatched ( input . symbol , 'etf' ); } catch { /* ignore */ }
2026-08-10 13:36:26 -04:00
return { added : true };
}),
/** Remove a custom ETF from the user's rotation tracking. */
removeCustomEtf : publicProcedure
. input ( z . object ({ symbol : z . string (). toUpperCase () }))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId ?? 'anonymous' ;
const r = ctx . db . prepare ( 'DELETE FROM rotation_custom_symbols WHERE owner_id=? AND symbol=?' ). run ( userId , input . symbol );
return { removed : r.changes > 0 };
}),
/** List the user's custom rotation ETFs. */
listCustomEtfs : publicProcedure.query ( async ({ ctx }) => {
const userId = ctx . userId ?? 'anonymous' ;
const rows = ctx . db . prepare ( 'SELECT symbol, name, grp, created_at FROM rotation_custom_symbols WHERE owner_id=? ORDER BY symbol' ). all ( userId ) as Array < { symbol : string ; name : string | null ; grp : string ; created_at : string } > ;
return rows . map (( r ) => ({ ... r , name : r.name ?? r . symbol }));
}),
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 )),
2026-08-10 13:36:26 -04:00
/** Alert-system observability: producer cadence + source freshness. */
alertStatus : adminProcedure.query (({ ctx }) => alertStatus ( ctx . db )),
2026-07-05 08:50:12 -04:00
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.' }); }
}),
2026-08-10 13:36:26 -04:00
/** Run GEX/VEX integrity audit (and optional as-of replay) for a symbol. */
dealerMapIntegrity : adminProcedure
. input ( z . object ({
symbol : z . string (). min ( 1 ). max ( 12 ),
/** When set, rebuild surface from chains with ts ≤ asOf (backtest). */
asOf : z.string (). min ( 10 ). optional (),
recompute : z.boolean (). optional (). default ( true ),
}))
. mutation ( async ({ ctx , input }) => {
const symbol = input . symbol . trim (). toUpperCase ();
const { auditDealerMap , integritySummary } = await import ( '../analysis/dealerMapIntegrity.ts' );
const {
recomputeDealerMapFromCacheDetailed ,
writeDealerMapDataQuality ,
} = await import ( '../analysis/dealerMapService.ts' );
const { replayDealerMapAt } = await import ( '../analysis/dealerMapReplay.ts' );
if ( input . asOf ) {
const replay = replayDealerMapAt ( ctx . db , symbol , input . asOf );
writeDealerMapDataQuality ( ctx . db , symbol , replay . integrity );
recordAudit ( ctx . db , ctx . userId ?? 'admin' , 'dealer.integrity' , symbol , {
mode : 'replay' ,
asOf : input.asOf ,
status : replay.integrity.status ,
ok : replay.ok ,
});
return {
mode : 'replay' as const ,
symbol ,
asOf : input.asOf ,
ok : replay.ok ,
summary : replay.summary ,
integrity : replay.integrity ,
integrityText : integritySummary ( replay . integrity ),
king : replay.map?.levels.king ?? null ,
spot : replay.spot ,
spotSource : replay.spotSource ,
surfaceRowCount : replay.surfaceRowCount ,
snapshotCompare : replay.snapshotCompare ,
};
}
let map = null as Awaited < ReturnType < typeof recomputeDealerMapFromCacheDetailed >>[ 'map' ];
let integrity = auditDealerMap ( null , null );
let keptPriorMap = false ;
let message : string | undefined ;
if ( input . recompute ) {
const detail = recomputeDealerMapFromCacheDetailed ( ctx . db , symbol , { write : true });
map = detail . map ;
integrity = detail . integrity ?? auditDealerMap ( detail . map , detail . surface );
keptPriorMap = detail . keptPriorMap ;
message = detail . message ;
if ( detail . integrity ) writeDealerMapDataQuality ( ctx . db , symbol , detail . integrity );
}
recordAudit ( ctx . db , ctx . userId ?? 'admin' , 'dealer.integrity' , symbol , {
mode : 'live' ,
status : integrity.status ,
keptPriorMap ,
});
return {
mode : 'live' as const ,
symbol ,
ok : integrity.status !== 'incomplete' ,
summary : message ?? integritySummary ( integrity ),
integrity ,
integrityText : integritySummary ( integrity ),
king : map?.levels.king ?? null ,
spot : map?.spot ?? null ,
keptPriorMap ,
cells : map?.cells.length ?? 0 ,
quality : map?.quality ?? null ,
};
}),
2026-07-12 12:55:59 -04:00
queueStatus : adminProcedure.query (({ ctx }) => ctx . queue . health ()),
2026-08-10 13:36:26 -04:00
/** Force-clear a vendor cool-down (operator recovery after rate-limit thrash). */
clearSourceCooldown : adminProcedure
. input ( z . object ({ sourceKind : z.string (). min ( 1 ) }))
. mutation (({ ctx , input }) => {
ctx . queue . clearSourceCooldownManual ( input . sourceKind );
return { ok : true , sourceKind : input.sourceKind };
}),
2026-07-12 12:55:59 -04:00
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 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 );
2026-08-10 13:36:26 -04:00
const kindPath = input . kind === 'sec-lint-holders' ? 'holders' : 'insiders' ;
2026-07-12 20:19:12 -04:00
for ( const sym of symbols ) {
2026-08-10 13:36:26 -04:00
try { await ctx . queue . queue ( ` ${ input . kind } : ${ kindPath } : ${ sym } ` ); } catch { /* ignore */ }
2026-07-12 20:19:12 -04:00
}
2026-08-10 13:36:26 -04:00
return { total : symbols.length , enqueued : symbols.length };
2026-07-12 20:19:12 -04:00
}),
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-25 13:11:50 -04:00
/** Get FINRA config (admin-only). */
finraConfig : adminProcedure.query (({ ctx }) => ({
baseUrl : getFinraBaseUrl ( ctx . db ),
})),
/** Update FINRA download URL (admin-only). */
finraConfigUpdate : adminProcedure
. input ( z . object ({ baseUrl : z.string (). url () }))
. mutation (({ ctx , input }) => setFinraBaseUrl ( ctx . db , input . baseUrl )),
2026-08-10 13:36:26 -04:00
/** Trigger FINRA 30-day backfill. Skips existing dates, rate-limited 500ms. */
finraBackfill : adminProcedure
. input ( z . object ({ days : z.number (). int (). min ( 1 ). max ( 200 ). optional (). default ( 30 ) }))
. mutation ( async ({ ctx , input }) => {
const { backfillFinra } = await import ( '../services/FinraIngestService.ts' );
const result = await backfillFinra ( ctx . db , input . days , getFinraBaseUrl ( ctx . db ));
recordAudit ( ctx . db , ctx . userId ! , 'finra.backfill' , null , { days : input.days , filesStored : result.filesStored , errors : result.errors.length });
return result ;
}),
/** Trigger FINRA bi-monthly short interest backfill (~24 months default). */
finraSiBackfill : adminProcedure
. input ( z . object ({ months : z.number (). int (). min ( 1 ). max ( 36 ). optional (). default ( 24 ) }))
. mutation ( async ({ ctx , input }) => {
const { backfillFinraSi } = await import ( '../services/FinraIngestService.ts' );
const result = await backfillFinraSi ( ctx . db , input . months );
recordAudit ( ctx . db , ctx . userId ! , 'finra-si.backfill' , null , { months : input.months , filesStored : result.filesStored , errors : result.errors.length });
return result ;
}),
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-08-10 13:36:26 -04:00
/** Per-type toggle list: which alert types the user receives, plus labels. */
listTypes : protectedProcedure
. query ( async ({ ctx }) => {
const userId = ctx . userId as string ;
const { listAlertTypeToggles } = await import ( '../db/alertSubscriptionRepository.ts' );
return listAlertTypeToggles ( ctx . db , userId );
}),
/** Toggle a whole alert type on/off for the current user (authoritative). */
toggleType : protectedProcedure
. input ( z . object ({ alertType : z.string (), enabled : z.boolean () }))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId as string ;
const { toggleAlertType } = await import ( '../db/alertSubscriptionRepository.ts' );
try {
return toggleAlertType ( ctx . db , userId , input . alertType , input . enabled );
} catch ( e ) {
throw new TRPCError ({
code : 'BAD_REQUEST' ,
message : e instanceof Error ? e . message : 'Failed to toggle alert type.' ,
});
}
}),
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 ) {
2026-08-10 13:36:26 -04:00
// One position per reported quarter (latest filed wins) — not filed_at pair only.
// Prior bug: only compared the two newest rows by file date, skipped intermediate
// quarters, and sometimes spanned non-adjacent periods (false mega-deltas).
const byQuarter = new Map < string , ( typeof cikRows )[ 0 ] >();
const sortedByFiled = [... cikRows ]. sort (( a , b ) =>
String ( b . filed_at ). localeCompare ( String ( a . filed_at )),
);
for ( const r of sortedByFiled ) {
if ( ! byQuarter . has ( r . reported_quarter )) byQuarter . set ( r . reported_quarter , r );
}
const quartersAsc = [... byQuarter . keys ()]. sort (( a , b ) => a . localeCompare ( b ));
for ( let i = 1 ; i < quartersAsc . length ; i ++ ) {
const prevQ = quartersAsc [ i - 1 ];
const currQ = quartersAsc [ i ];
const prev = byQuarter . get ( prevQ ) ! ;
const curr = byQuarter . get ( currQ ) ! ;
const prevShares = prev . shares ?? 0 ;
const currShares = curr . shares ?? 0 ;
const delta = currShares - prevShares ;
if ( delta === 0 ) continue ;
2026-07-05 08:50:12 -04:00
2026-08-10 13:36:26 -04:00
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' ;
2026-07-05 08:50:12 -04:00
2026-08-10 13:36:26 -04:00
allFlow . push ({
filerCik : cik ,
filerName : curr.filer_name ,
form : curr.form ,
prevShares ,
currShares ,
delta ,
classification ,
reportedQuarter : currQ ,
});
}
2026-07-05 08:50:12 -04:00
}
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
}));
2026-08-10 13:36:26 -04:00
// Compute institutional ownership % across all tracked funds.
const ownership = computeOwnershipPercentages ( ctx . db , symbol );
return { symbol , flow : allFlow , quarters , filings , ownership };
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 };
}),
2026-08-10 13:36:26 -04:00
/** Analyst ratings (upgrades/downgrades + consensus). Cache first; live only when Yahoo not cooling down. */
2026-07-23 18:02:24 -04:00
analystRatings : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
const { fetchAndStoreAnalystRatings , getAnalystRatings } = await import ( '../services/analystRatingsService.ts' );
const cached = getAnalystRatings ( ctx . db , symbol );
if ( cached && ! cached . stale ) return { symbol , ratings : cached.ratings , consensus : cached.consensus };
2026-08-10 13:36:26 -04:00
// Respect shared yfinance cool-down — never stampede Edge from this panel.
if ( ctx . queue . isSourceCoolingDown ( 'yfinance' )) {
if ( cached ) return { symbol , ratings : cached.ratings , consensus : cached.consensus , stale : true };
return { symbol , ratings : [], consensus : null , error : 'Yahoo cooling down — ratings will refresh when rate limit clears' };
}
const result = await fetchAndStoreAnalystRatings ( ctx . db , symbol , {
onRateLimit : () => { try { ctx . queue . applySourceCooldown ( 'yfinance' , 'analystRatings 429' ); } catch { /* ignore */ } },
});
2026-07-23 18:02:24 -04:00
if ( 'error' in result ) {
if ( cached ) return { symbol , ratings : cached.ratings , consensus : cached.consensus , stale : true };
return { symbol , ratings : [], consensus : null , error : result.error };
}
2026-08-10 13:36:26 -04:00
return { symbol , ratings : result.ratings , consensus : result.consensus };
}),
2026-07-25 12:09:21 -04:00
2026-08-10 13:36:26 -04:00
/** Short interest: FINRA bi-monthly (outstanding, regulatory gold) +
* FINRA daily volume (weekly activity chart) + NASDAQ/Yahoo (cross-check).
* All three outstanding sources compared for discrepancy (apples-to-apples). */
2026-07-25 12:59:43 -04:00
shortInterest : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const symbol = input . symbol . toUpperCase ();
2026-08-10 13:36:26 -04:00
const [ yfEntry , ndqEntry , frVolEntry , frSiEntry ] = await Promise . all ([
2026-07-25 12:59:43 -04:00
ctx . cache . get < Record < string , unknown >>( `yfinance:shortinterest: ${ symbol } ` ),
ctx . cache . get < Record < string , unknown >>( `nasdaq:nasdaqShortinterest: ${ symbol } ` ),
2026-07-25 13:03:49 -04:00
ctx . cache . get < Record < string , unknown >>( `finra-bulk:finraShortinterest: ${ symbol } ` ),
2026-08-10 13:36:26 -04:00
ctx . cache . get < Record < string , unknown >>( `finra-si:finraSiBiweekly: ${ symbol } ` ),
2026-07-25 12:59:43 -04:00
]);
2026-07-25 13:23:07 -04:00
let yf = yfEntry . value ;
let ndq = ndqEntry . value ;
2026-08-10 13:36:26 -04:00
let frVol = frVolEntry . value ;
let frSi = frSiEntry . value ;
2026-07-25 12:59:43 -04:00
2026-08-10 13:36:26 -04:00
// Background refresh via shared queue only — no live Yahoo/Nasdaq on request path.
if ( ! yf ) {
try { await ctx . queue . queue ( `yfinance:shortinterest: ${ symbol } ` ); } catch { /* ignore */ }
2026-07-25 13:23:07 -04:00
}
2026-08-10 13:36:26 -04:00
if ( ! ndq ) {
try { await ctx . queue . queue ( `nasdaq:nasdaqShortinterest: ${ symbol } ` ); } catch { /* ignore */ }
}
if ( ! frVol ) {
try {
const row = ctx . db . prepare ( 'SELECT settlement_date,exchange,short_volume,short_exempt,total_volume,days_to_cover FROM finra_short_interest WHERE symbol=? ORDER BY settlement_date DESC LIMIT 1' ). get ( symbol ) as Record < string , unknown > | undefined ;
if ( row ) {
const v = {
shortVolume : row.short_volume , shortExempt : row.short_exempt , totalVolume : row.total_volume ,
settlementDate : row.settlement_date , exchange : row.exchange , daysToCover : row.days_to_cover ,
};
await ctx . cache . set ( `finra-bulk:finraShortinterest: ${ symbol } ` , v , 'short_interest' , {
fetchedAt : new Date (). toISOString (), sourceKind : 'finra-bulk' , rawSourceId : symbol ,
});
frVol = v as Record < string , unknown >;
2026-07-25 13:03:49 -04:00
}
2026-08-10 13:36:26 -04:00
} catch { /* ignore */ }
}
if ( ! frSi ) {
try {
const row = ctx . db . prepare ( 'SELECT current_short_position,previous_short_position,avg_daily_volume,days_to_cover,change_percent,settlement_date,ingested_at FROM finra_short_interest_biweekly WHERE symbol=? ORDER BY settlement_date DESC LIMIT 1' ). get ( symbol ) as Record < string , unknown > | undefined ;
if ( row ) {
const v = {
currentShortPosition : row.current_short_position , previousShortPosition : row.previous_short_position ,
avgDailyVolume : row.avg_daily_volume , daysToCover : row.days_to_cover ,
changePercent : row.change_percent , settlementDate : row.settlement_date ,
};
await ctx . cache . set ( `finra-si:finraSiBiweekly: ${ symbol } ` , v , 'short_interest' , {
fetchedAt : new Date (). toISOString (), sourceKind : 'finra-si' , rawSourceId : symbol ,
});
frSi = v as Record < string , unknown >;
}
} catch { /* ignore */ }
2026-07-25 13:03:49 -04:00
}
2026-08-10 13:36:26 -04:00
const comp = [ { s : 'finra' , v : ( frSi ? . currentShortPosition as number | undefined ) ?? null }, { s : 'nasdaq' , v :ndq?.sharesShort as number | null }, { s : 'yfinance' , v :yf?.sharesShort as number | null } ]. filter ( x => x . v != null && x . v > 0 );
let discPct : number | null = null , discBetw : string | null = null ;
if ( comp . length >= 2 ){ const s = [... comp ]. sort (( a , b ) => ( b . v ?? 0 ) - ( a . v ?? 0 )); const hi = s [ 0 ]. v ! , lo = s [ s . length - 1 ]. v ! ; const p = Math . abs ( hi - lo ) / lo ; if ( p > 0.10 ){ discPct = Math . round ( p * 100 ); discBetw = ` ${ s [ 0 ]. s } vs ${ s [ s . length - 1 ]. s } ` ; } }
const sharesShort = frSi ? . currentShortPosition ?? ndq ? . sharesShort ?? yf ? . sharesShort ?? null ;
const daysToCover = frSi ? . daysToCover ?? ndq ? . daysToCover ?? yf ? . shortRatio ?? null ;
const settlementDate = frSi ? . settlementDate ?? ndq ? . settlementDate ?? yf ? . dateShortInterest ?? null ;
2026-07-25 12:59:43 -04:00
const history = ndq ? . history ?? null ;
2026-08-10 13:36:26 -04:00
const biRows = ctx . db . prepare ( 'SELECT settlement_date, current_short_position, previous_short_position, days_to_cover, change_percent FROM finra_short_interest_biweekly WHERE symbol=? AND settlement_date >= date(\'now\',\'-24 months\') ORDER BY settlement_date ASC' ). all ( symbol ) as Array < { settlement_date :string ; current_short_position :number ; previous_short_position :number | null ; days_to_cover :number | null ; change_percent :number | null } > ;
const finraBiweeklyHistory = biRows . map ( r => ({ settlementDate :r.settlement_date , currentShortPosition :r.current_short_position , previousShortPosition :r.previous_short_position , daysToCover :r.days_to_cover , changePercent :r.change_percent }));
const volRows = ctx . db . prepare ( 'SELECT settlement_date,short_volume,total_volume FROM finra_short_interest WHERE symbol=? AND settlement_date >= date(\'now\',\'-180 days\') ORDER BY settlement_date ASC' ). all ( symbol ) as Array < { settlement_date :string ; short_volume :number ; total_volume :number } > ;
const wm = new Map < string ,{ sv : number ; tv : number ; e : string }>();
for ( const r of volRows ){ const d = new Date ( r . settlement_date + 'T00:00:00Z' ); const mon = d . getUTCDay () === 0?6 :d.getUTCDay () - 1 ; const we = new Date ( d ); we . setUTCDate ( d . getUTCDate () + mon ); const k = we . toISOString (). slice ( 0 , 10 ); const e = wm . get ( k ) || { sv :0 , tv :0 , e :k }; e . sv += r . short_volume ; e . tv += r . total_volume ; wm . set ( k , e ); }
const finraWeekly = [... wm . values ()]. sort (( a , b ) => a . e . localeCompare ( b . e )). map ( w => ({ weekEnding :w.e , shortVolume :w.sv , totalVolume :w.tv , shortPct :w.tv > 0 ? Math . round ( w . sv / w . tv * 10000 ) / 100 :0 }));
return { symbol , sharesShort , sharesShortPriorMonth :yf?.sharesShortPriorMonth??null , sharesPercentSharesOut :yf?.sharesPercentSharesOut??null , shortRatio :yf?.shortRatio??null , shortPercentOfFloat :yf?.shortPercentOfFloat??null , dateShortInterest :yf?.dateShortInterest??null , floatShares :yf?.floatShares??null , sharesOutstanding :yf?.sharesOutstanding??null , daysToCover , settlementDate , history , finraBiweeklyHistory , finraWeekly , discrepancyPct :discPct , discrepancyBetween :discBetw , sources : { yfinance : { available :!! yf , stale :yfEntry.isStale }, nasdaq : { available :!! ndq , stale :ndqEntry.isStale }, finra : { available :!! frSi , stale :frSiEntry.isStale }}, stale :yfEntry.isStale && ndqEntry . isStale && frSiEntry . isStale };
2026-07-25 12:10:46 -04:00
}),
});
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-08-10 13:36:26 -04:00
try { await ctx . cache . subscribe ( input . symbol , 'equity' ); } catch { /* ignore */ }
try { queueSecFetch ( ctx . db , input . symbol ); } catch { /* ignore */ }
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-08-10 13:36:26 -04:00
if ( removed ) {
materializeClassificationWatchlists ( ctx . db , userId );
try { await ctx . cache . unsubscribe ( input . symbol ); } catch { /* ignore */ }
}
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. */
2026-08-10 13:36:26 -04:00
addHolding : publicProcedure
2026-07-05 08:50:12 -04:00
. input ( z . object ({ symbol : z . string (). toUpperCase (), shares : z.number (). positive (), avgCost : z.number (). min ( 0 ) }))
. mutation ( async ({ ctx , input }) => {
2026-08-10 13:36:26 -04:00
const userId = ctx . userId ?? 'anonymous' ;
2026-07-05 08:50:12 -04:00
const { addHolding } = await import ( '../db/portfolioRepository.ts' );
const created = addHolding ( ctx . db , userId , input . symbol , input . shares , input . avgCost );
2026-08-10 13:36:26 -04:00
try {
const { materializePortfolioWatchlist } = await import ( '../db/watchlistRepository.ts' );
materializePortfolioWatchlist ( ctx . db , userId );
} catch { /* ignore */ }
if ( created ) {
try { await ctx . cache . subscribe ( input . symbol , 'equity' ); } catch { /* ignore */ }
try { queueSecFetch ( ctx . db , input . symbol ); } catch { /* ignore */ }
}
2026-07-05 08:50:12 -04:00
return { created };
}),
2026-08-10 13:36:26 -04:00
/** Update an existing holding's shares and/or avg cost. */
updateHolding : publicProcedure
. input ( z . object ({
symbol : z . string (). toUpperCase (),
shares : z.number (). positive (). optional (),
avgCost : z.number (). min ( 0 ). optional (),
}))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId ?? 'anonymous' ;
const { updateHolding } = await import ( '../db/portfolioRepository.ts' );
const updated = updateHolding ( ctx . db , userId , input . symbol , { shares : input.shares , avgCost : input.avgCost });
try {
const { materializePortfolioWatchlist } = await import ( '../db/watchlistRepository.ts' );
materializePortfolioWatchlist ( ctx . db , userId );
} catch { /* ignore */ }
return { updated };
}),
2026-07-05 08:50:12 -04:00
/** Remove (close) a holding. */
2026-08-10 13:36:26 -04:00
removeHolding : publicProcedure
2026-07-05 08:50:12 -04:00
. input ( z . object ({ symbol : z . string (). toUpperCase () }))
. mutation ( async ({ ctx , input }) => {
2026-08-10 13:36:26 -04:00
const userId = ctx . userId ?? 'anonymous' ;
2026-07-05 08:50:12 -04:00
const { removeHolding } = await import ( '../db/portfolioRepository.ts' );
const removed = removeHolding ( ctx . db , userId , input . symbol );
2026-08-10 13:36:26 -04:00
try {
const { materializePortfolioWatchlist } = await import ( '../db/watchlistRepository.ts' );
materializePortfolioWatchlist ( ctx . db , userId );
} catch { /* ignore */ }
if ( removed ) {
await ctx . cache . unsubscribe ( input . symbol );
}
2026-07-05 08:50:12 -04:00
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 };
}
}),
});
2026-08-10 13:36:26 -04:00
// ─── Dealer Flow (GEX/VEX map) — cache-only reads, schedule-on-miss (ADR-0009) ───
const dealerMapRouter = router ({
/** Full map + Layer-0 educator. Never hits Yahoo on this path. */
get : protectedProcedure
. input ( z . object ({
symbol : z . string (). min ( 1 ),
forceSchedule : z.boolean (). optional (),
maxExpiries : z.number (). int (). min ( 1 ). max ( 8 ). optional (),
/** classic = call+/put− ; dealer_inventory = Heatseeker-style sign flip */
convention : z.enum ([ 'classic_call_pos_put_neg' , 'dealer_inventory' ]). optional (),
}))
. query ( async ({ ctx , input }) => {
const { getDealerMap } = await import ( '../analysis/dealerMapService.ts' );
return getDealerMap (
{ db : ctx.db , cache : ctx.cache , queue : ctx.queue },
input . symbol ,
{
forceSchedule : input.forceSchedule ,
maxExpiries : input.maxExpiries ,
convention : input.convention ,
},
);
}),
/** Compact levels for Research strip / chips. */
levels : protectedProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const { getDealerMap } = await import ( '../analysis/dealerMapService.ts' );
const result = await getDealerMap (
{ db : ctx.db , cache : ctx.cache , queue : ctx.queue },
input . symbol ,
);
if ( ! result . map ) {
return {
symbol : input . symbol . toUpperCase (),
status : result.status ,
scheduled : result.scheduled ,
levels : null as null ,
spot : null as number | null ,
delayNote : null as string | null ,
providerId : result.providerId ,
};
}
return {
symbol : result . map . symbol ,
status : result.status ,
scheduled : result.scheduled ,
levels : result.map.levels ,
spot : result.map.spot ,
delayNote : result.map.delayNote ,
providerId : result.map.providerId ,
regimeAtSpot : result.map.regimeAtSpot ,
scenario : result.map.scenario ,
patterns : result.map.patterns.map (( p ) => p . id ),
};
}),
/** Scenario + Layer-0 educator only. */
scenario : protectedProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const { getDealerMap } = await import ( '../analysis/dealerMapService.ts' );
const result = await getDealerMap (
{ db : ctx.db , cache : ctx.cache , queue : ctx.queue },
input . symbol ,
);
return {
symbol : input . symbol . toUpperCase (),
status : result.status ,
scenario : result.map?.scenario ?? 'insufficient_data' ,
regimeAtSpot : result.map?.regimeAtSpot ?? 'mixed' ,
patterns : result.map?.patterns ?? [],
educator : result.educator ,
delayNote : result.map?.delayNote ?? result . message ?? null ,
providerId : result.providerId ,
};
}),
/** Velocity summary when prior snapshot exists. */
velocity : protectedProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const { getDealerMap } = await import ( '../analysis/dealerMapService.ts' );
const result = await getDealerMap (
{ db : ctx.db , cache : ctx.cache , queue : ctx.queue },
input . symbol ,
);
return {
symbol : input . symbol . toUpperCase (),
status : result.status ,
velocity : result.map?.velocity ?? null ,
message : result.map?.velocity
? null
: 'No prior-day snapshot yet - velocity appears after the first daily recompute.' ,
};
}),
/** L1 beginner explain via user's OpenAI-compatible endpoint (or L0 fallback). */
explain : protectedProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ) }))
. mutation ( async ({ ctx , input }) => {
try {
const { getDealerMap } = await import ( '../analysis/dealerMapService.ts' );
const { explainDealerMap } = await import ( '../analysis/dealerMapExplain.ts' );
const result = await getDealerMap (
{ db : ctx.db , cache : ctx.cache , queue : ctx.queue },
input . symbol ,
);
if ( ! result . map ) {
return {
ok : false ,
source : 'error' as const ,
headline : 'No map available' ,
sections : {
whatMapShows : result.message ?? 'Map not ready yet.' ,
keyLevels : [] as string [],
scenarioInPlainEnglish : '' ,
questionsToAsk : [] as string [],
caveats : [ result . message ?? '' ],
dataFreshness : '' ,
},
error : result.message ,
};
}
return await explainDealerMap ( ctx . db , ctx . userId ! , result . map );
} catch ( e ) {
// Never surface an uncaught 500 for AI explain - degrade to structured error.
console . error ( '[dealerMap.explain]' , e );
const msg = e instanceof Error ? e.message : String ( e );
return {
ok : false ,
source : 'error' as const ,
headline : 'Interpretation unavailable' ,
sections : {
whatMapShows : 'The AI explanation step failed. The map itself is unaffected.' ,
keyLevels : [] as string [],
scenarioInPlainEnglish : '' ,
questionsToAsk : [
'Retry in a moment?' ,
'Is your OpenAI-compatible endpoint reachable from Settings → Test?' ,
],
caveats : [ msg ],
dataFreshness : '' ,
},
error : msg ,
};
}
}),
});
// ─── Dealer Flow Study Desk (educational practice + auto-grade) ────────────
const HIST_MIN_N = 5 ;
/** Mentor structure rates only influence rank when enough decided grades exist. */
const MENTOR_HIST_MIN_N = 20 ;
function studyScorecardForUser ( db : Context [ 'db' ], userId : string ) {
const rows = db . prepare (
`SELECT hypothesis_id, grade, score FROM dealer_study_setups WHERE owner_id=?` ,
). all ( userId ) as Array < { hypothesis_id : string ; grade : string | null ; score : number | null } > ;
type Agg = {
hypothesisId : string ;
n : number ;
wins : number ;
losses : number ;
expired : number ;
incomplete : number ;
scoreSum : number ;
scoreN : number ;
};
const map = new Map < string , Agg >();
for ( const r of rows ) {
const a = map . get ( r . hypothesis_id ) ?? {
hypothesisId : r.hypothesis_id ,
n : 0 ,
wins : 0 ,
losses : 0 ,
expired : 0 ,
incomplete : 0 ,
scoreSum : 0 ,
scoreN : 0 ,
};
a . n ++ ;
if ( r . grade === 'win' ) a . wins ++ ;
else if ( r . grade === 'loss' ) a . losses ++ ;
else if ( r . grade === 'expired' ) a . expired ++ ;
else a . incomplete ++ ;
if ( typeof r . score === 'number' ) {
a . scoreSum += r . score ;
a . scoreN ++ ;
}
map . set ( r . hypothesis_id , a );
}
return [... map . values ()]. map (( a ) => {
const decided = a . wins + a . losses ;
return {
hypothesisId : a.hypothesisId ,
n : a.n ,
wins : a.wins ,
losses : a.losses ,
expired : a.expired ,
incomplete : a.incomplete ,
decidedN : decided ,
winRate : decided > 0 ? a . wins / decided : null ,
avgScore : a.scoreN > 0 ? a . scoreSum / a.scoreN : null ,
};
}). sort (( x , y ) => y . n - x . n );
}
/** Path-match rates by mentor claim_type (all handles combined for structure type). */
function mentorClaimScorecard ( db : Context [ 'db' ], userId : string ) {
const rows = db . prepare (
`SELECT claim_type, grade, score FROM mentor_calls
WHERE owner_id=? AND status IN ('confirmed','graded')` ,
). all ( userId ) as Array < { claim_type : string ; grade : string | null ; score : number | null } > ;
type Agg = {
claimType : string ;
n : number ;
wins : number ;
losses : number ;
scoreSum : number ;
scoreN : number ;
};
const map = new Map < string , Agg >();
for ( const r of rows ) {
const a = map . get ( r . claim_type ) ?? {
claimType : r.claim_type ,
n : 0 ,
wins : 0 ,
losses : 0 ,
scoreSum : 0 ,
scoreN : 0 ,
};
a . n ++ ;
if ( r . grade === 'win' ) a . wins ++ ;
else if ( r . grade === 'loss' ) a . losses ++ ;
if ( typeof r . score === 'number' ) {
a . scoreSum += r . score ;
a . scoreN ++ ;
}
map . set ( r . claim_type , a );
}
return [... map . values ()]. map (( a ) => {
const decided = a . wins + a . losses ;
return {
claimType : a.claimType ,
n : a.n ,
decidedN : decided ,
winRate : decided > 0 ? a . wins / decided : null ,
avgScore : a.scoreN > 0 ? a . scoreSum / a.scoreN : null ,
};
});
}
const dealerStudyRouter = router ({
/** Propose study setups from current cache-only map (+ hist / optional mentor rank). */
propose : protectedProcedure
. input ( z . object ({
symbol : z . string (). min ( 1 ),
/** When true, blend mentor structure path-match rates into rank (default false). */
useMentorHistory : z.boolean (). optional (),
}))
. query ( async ({ ctx , input }) => {
const { getDealerMap } = await import ( '../analysis/dealerMapService.ts' );
const { proposeStudySetups } = await import ( '../analysis/dealerStudyEngine.ts' );
const { HYPOTHESIS_TO_CLAIM } = await import ( '../analysis/mentorCallExtract.ts' );
const useMentor = input . useMentorHistory === true ;
const result = await getDealerMap (
{ db : ctx.db , cache : ctx.cache , queue : ctx.queue },
input . symbol ,
);
if ( ! result . map ) {
return {
symbol : input . symbol . toUpperCase (),
proposals : [] as Array < ReturnType < typeof proposeStudySetups > [ number ] & {
histWinRate : number | null ;
histN : number ;
histAvgScore : number | null ;
mentorWinRate : number | null ;
mentorN : number ;
mentorClaimType : string | null ;
rankScore : number ;
}>,
message : result.message ?? 'Map not ready yet.' ,
status : result.status ,
useMentorHistory : useMentor ,
};
}
const card = studyScorecardForUser ( ctx . db , ctx . userId ! );
const byH = new Map ( card . map (( c ) => [ c . hypothesisId , c ]));
const mentorByClaim = new Map (
mentorClaimScorecard ( ctx . db , ctx . userId ! ). map (( c ) => [ c . claimType , c ]),
);
const proposals = proposeStudySetups ( result . map ). map (( p ) => {
const h = byH . get ( p . hypothesisId );
const decidedN = h ? . decidedN ?? 0 ;
const histWinRate = decidedN >= HIST_MIN_N ? h ! . winRate : null ;
const histAvgScore = decidedN >= HIST_MIN_N ? h ! . avgScore : null ;
const claimType = HYPOTHESIS_TO_CLAIM [ p . hypothesisId ] ?? null ;
const m = claimType ? mentorByClaim . get ( claimType ) : undefined ;
const mentorDecided = m ? . decidedN ?? 0 ;
// Always surface mentor stats when available; only blend into rank when toggled + N large enough.
const mentorWinRate = mentorDecided > 0 ? m ! . winRate : null ;
const mentorN = mentorDecided ;
const mentorForRank =
useMentor && mentorDecided >= MENTOR_HIST_MIN_N ? mentorWinRate : null ;
let rankScore = p . confidence ;
if ( histWinRate != null && mentorForRank != null ) {
rankScore = p . confidence * 0.5 + histWinRate * 0.3 + mentorForRank * 0.2 ;
} else if ( histWinRate != null ) {
rankScore = p . confidence * 0.6 + histWinRate * 0.4 ;
} else if ( mentorForRank != null ) {
rankScore = p . confidence * 0.7 + mentorForRank * 0.3 ;
}
return {
... p ,
histWinRate ,
histN : decidedN ,
histAvgScore ,
mentorWinRate ,
mentorN ,
mentorClaimType : claimType ,
rankScore ,
};
}). sort (( a , b ) => b . rankScore - a . rankScore );
return {
symbol : result . map . symbol ,
proposals ,
message : null as string | null ,
status : result.status ,
useMentorHistory : useMentor ,
};
}),
/** Log a study setup for later auto-grade. */
log : protectedProcedure
. input ( z . object ({
symbol : z . string (). min ( 1 ),
hypothesisId : z.string (). min ( 1 ),
title : z.string (). min ( 1 ),
bias : z.enum ([ 'educational_long' , 'educational_short' , 'educational_range' ]),
mapReading : z.string (),
studyQuestion : z.string (),
entry : z.number (),
invalidation : z.number (),
target : z.number (),
horizonDays : z.number (). int (). min ( 1 ). max ( 30 ),
confidence : z.number (). optional (),
disclaimer : z.string (). optional (),
}))
. mutation ( async ({ ctx , input }) => {
const { getDealerMap } = await import ( '../analysis/dealerMapService.ts' );
const result = await getDealerMap (
{ db : ctx.db , cache : ctx.cache , queue : ctx.queue },
input . symbol ,
);
const id = randomUUID ();
const loggedAt = new Date (). toISOString ();
const snapshot = result . map
? JSON . stringify ({
symbol : result . map . symbol ,
spot : result.map.spot ,
asOf : result.map.asOf ,
regimeAtSpot : result.map.regimeAtSpot ,
scenario : result.map.scenario ,
levels : result.map.levels ,
patterns : result.map.patterns ,
})
: '{}' ;
const disclaimer =
input . disclaimer ??
'Educational observation only.' ;
ctx . db . prepare ( `
INSERT INTO dealer_study_setups (
id, owner_id, symbol, hypothesis_id, title, bias, map_reading, study_question,
entry, invalidation, target, horizon_days, confidence, map_snapshot,
status, logged_at, disclaimer
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'open', ?, ?)
` ). run (
id ,
ctx . userId ! ,
input . symbol . toUpperCase (),
input . hypothesisId ,
input . title ,
input . bias ,
input . mapReading ,
input . studyQuestion ,
input . entry ,
input . invalidation ,
input . target ,
input . horizonDays ,
input . confidence ?? null ,
snapshot ,
loggedAt ,
disclaimer ,
);
// Ensure daily candles are in demand for later grading (ADR-0009).
try {
2026-08-11 22:25:17 -04:00
await ctx . cache . bumpToWatched ( input . symbol . toUpperCase (), 'equity' );
2026-08-10 13:36:26 -04:00
} catch { /* optional */ }
return { ok : true as const , id , loggedAt };
}),
list : protectedProcedure
. input ( z . object ({
symbol : z . string (). optional (),
limit : z.number (). int (). min ( 1 ). max ( 100 ). optional (),
}). optional ())
. query ( async ({ ctx , input }) => {
const limit = input ? . limit ?? 30 ;
const symbol = input ? . symbol ? . toUpperCase ();
const rows = symbol
? ctx . db . prepare (
`SELECT * FROM dealer_study_setups WHERE owner_id=? AND symbol=? ORDER BY logged_at DESC LIMIT ?` ,
). all ( ctx . userId ! , symbol , limit )
: ctx . db . prepare (
`SELECT * FROM dealer_study_setups WHERE owner_id=? ORDER BY logged_at DESC LIMIT ?` ,
). all ( ctx . userId ! , limit );
return rows as Array < Record < string , unknown >>;
}),
grade : protectedProcedure
. input ( z . object ({ id : z.string (). min ( 1 ) }))
. mutation ( async ({ ctx , input }) => {
const { gradeStudySetup } = await import ( '../analysis/dealerStudyEngine.ts' );
const row = ctx . db . prepare (
`SELECT * FROM dealer_study_setups WHERE id=? AND owner_id=?` ,
). get ( input . id , ctx . userId ! ) as {
id : string ;
symbol : string ;
bias : 'educational_long' | 'educational_short' | 'educational_range' ;
entry : number ;
invalidation : number ;
target : number ;
horizon_days : number ;
logged_at : string ;
} | undefined ;
if ( ! row ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Study setup not found.' });
const candles = ctx . db . prepare (
`SELECT ts,o,h,l,c FROM price_candles WHERE symbol=? AND timeframe='1d' ORDER BY ts ASC` ,
). all ( row . symbol ) as Array < { ts : string ; o : number ; h : number ; l : number ; c : number } > ;
const result = gradeStudySetup (
{
bias : row.bias ,
entry : row.entry ,
invalidation : row.invalidation ,
target : row.target ,
horizonDays : row.horizon_days ,
loggedAt : row.logged_at ,
},
candles . map (( b ) => ({ ts : b.ts , o : b.o , h : b.h , l : b.l , c : b.c })),
);
const status = result . grade === 'incomplete' ? 'open' : 'graded' ;
ctx . db . prepare ( `
UPDATE dealer_study_setups
SET status=?, grade=?, score=?, grade_summary=?, grade_path=?, graded_at=?
WHERE id=? AND owner_id=?
` ). run (
status ,
result . grade ,
result . score ,
result . summary ,
JSON . stringify ( result . pathNotes ),
result . gradedAt ,
row . id ,
ctx . userId ! ,
);
return { ok : true as const , ... result , status };
}),
gradeDue : protectedProcedure.mutation ( async ({ ctx }) => {
const { gradeStudySetup } = await import ( '../analysis/dealerStudyEngine.ts' );
const open = ctx . db . prepare (
`SELECT * FROM dealer_study_setups WHERE owner_id=? AND (status='open' OR grade='incomplete')` ,
). all ( ctx . userId ! ) as Array < {
id : string ;
symbol : string ;
bias : 'educational_long' | 'educational_short' | 'educational_range' ;
entry : number ;
invalidation : number ;
target : number ;
horizon_days : number ;
logged_at : string ;
} > ;
let graded = 0 ;
let incomplete = 0 ;
for ( const row of open ) {
const candles = ( ctx . db . prepare (
`SELECT ts,o,h,l,c FROM price_candles WHERE symbol=? AND timeframe='1d' ORDER BY ts ASC` ,
). all ( row . symbol ) as Array < { ts : string ; o : number ; h : number ; l : number ; c : number } > );
const result = gradeStudySetup (
{
bias : row.bias ,
entry : row.entry ,
invalidation : row.invalidation ,
target : row.target ,
horizonDays : row.horizon_days ,
loggedAt : row.logged_at ,
},
candles ,
);
const status = result . grade === 'incomplete' ? 'open' : 'graded' ;
if ( status === 'graded' ) graded ++ ;
else incomplete ++ ;
ctx . db . prepare ( `
UPDATE dealer_study_setups
SET status=?, grade=?, score=?, grade_summary=?, grade_path=?, graded_at=?
WHERE id=? AND owner_id=?
` ). run (
status ,
result . grade ,
result . score ,
result . summary ,
JSON . stringify ( result . pathNotes ),
result . gradedAt ,
row . id ,
ctx . userId ! ,
);
}
return { ok : true as const , considered : open.length , graded , incomplete };
}),
scorecard : protectedProcedure.query ( async ({ ctx }) => {
return studyScorecardForUser ( ctx . db , ctx . userId ! ). map (({ decidedN : _d , ... rest }) => rest );
}),
/** Copy a study into journal as a planned trade draft (not an order). */
promoteToJournal : protectedProcedure
. input ( z . object ({ studyId : z.string (). min ( 1 ) }))
. mutation ( async ({ ctx , input }) => {
const row = ctx . db . prepare (
`SELECT * FROM dealer_study_setups WHERE id=? AND owner_id=?` ,
). get ( input . studyId , ctx . userId ! ) as {
id : string ;
symbol : string ;
hypothesis_id : string ;
title : string ;
bias : string ;
map_reading : string ;
study_question : string ;
entry : number ;
invalidation : number ;
target : number ;
horizon_days : number ;
} | undefined ;
if ( ! row ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Study setup not found.' });
const tradeId = randomUUID ();
const date = new Date (). toISOString (). slice ( 0 , 10 );
const targets = JSON . stringify ([
{ price : row.target , reason : `Study target ( ${ row . hypothesis_id } )` },
]);
const justifications = JSON . stringify ({
entryWhy : row.map_reading ,
stopWhy : `Study invalidation for ${ row . hypothesis_id } ` ,
targetWhy : row.study_question ,
studySetupId : row.id ,
educational : true ,
note : 'Draft from Study Desk.' ,
});
const env =
row . bias === 'educational_range'
? 'ranging'
: row . bias === 'educational_long'
? 'trending'
: 'transitioning' ;
ctx . db . prepare ( `
INSERT INTO trades (
id, owner_id, trade_id, symbol, date, status, environment, tier,
confluence_score, risk_pct, position_size, entry_price, stop_loss,
targets, justifications, checklist_completed
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0)
` ). run (
tradeId ,
ctx . userId ! ,
tradeId ,
row . symbol ,
date ,
'planned' ,
env ,
'B' ,
null ,
null ,
null ,
row . entry ,
row . invalidation ,
targets ,
justifications ,
);
return {
ok : true as const ,
tradeId ,
message :
'Journal draft created (planned). Review in Plan/Journal - not an order to trade real money.' ,
};
}),
/** Owner-scoped CSV of study rows for offline review. */
exportCsv : protectedProcedure
. input ( z . object ({
symbol : z . string (). optional (),
limit : z.number (). int (). min ( 1 ). max ( 2000 ). optional (),
}). optional ())
. query ( async ({ ctx , input }) => {
const limit = input ? . limit ?? 500 ;
const symbol = input ? . symbol ? . toUpperCase ();
const rows = symbol
? ctx . db . prepare (
`SELECT * FROM dealer_study_setups WHERE owner_id=? AND symbol=? ORDER BY logged_at DESC LIMIT ?` ,
). all ( ctx . userId ! , symbol , limit )
: ctx . db . prepare (
`SELECT * FROM dealer_study_setups WHERE owner_id=? ORDER BY logged_at DESC LIMIT ?` ,
). all ( ctx . userId ! , limit );
const cols = [
'id' , 'symbol' , 'hypothesis_id' , 'title' , 'bias' , 'entry' , 'invalidation' , 'target' ,
'horizon_days' , 'confidence' , 'status' , 'grade' , 'score' , 'grade_summary' , 'logged_at' , 'graded_at' ,
];
const escape = ( v : unknown ) => {
if ( v == null ) return '' ;
const s = String ( v );
if ( /[",\n]/ . test ( s )) return `" ${ s . replace ( /"/g , '""' ) } "` ;
return s ;
};
const lines = [ cols . join ( ',' )];
for ( const r of rows as Array < Record < string , unknown >>) {
lines . push ( cols . map (( c ) => escape ( r [ c ])). join ( ',' ));
}
return {
filename : `dealer-study- ${ symbol ?? 'all' } - ${ new Date (). toISOString (). slice ( 0 , 10 ) } .csv` ,
csv : lines.join ( '\n' ),
rowCount : rows.length ,
};
}),
});
// ─── Mentor call ledger (local path-match grades; privacy-first) ───────────
const mentorLedgerRouter = router ({
/** Scan local harvest raw/ for draft mentor calls (no cloud LLM). */
importFromHarvest : protectedProcedure
. input ( z . object ({
handle : z.string (). optional (),
maxFiles : z.number (). int (). min ( 1 ). max ( 500 ). optional (),
}). optional ())
. mutation ( async ({ ctx , input }) => {
const { readdirSync , readFileSync , existsSync , statSync } = await import ( 'node:fs' );
const { join } = await import ( 'node:path' );
const { extractMentorCallFromMarkdown } = await import ( '../analysis/mentorCallExtract.ts' );
const rawRoot = join ( process . cwd (), 'data' , 'dealer-flow-raw' , 'by-handle' );
if ( ! existsSync ( rawRoot )) {
return { ok : true as const , imported : 0 , skipped : 0 , message : 'No harvest folder yet. Run npm run dealer-flow:harvest first.' };
}
const handles = input ? . handle
? [ input . handle . replace ( /^@/ , '' )]
: readdirSync ( rawRoot ). filter (( h ) => h !== '_template' && ! h . startsWith ( '.' ));
const maxFiles = input ? . maxFiles ?? 200 ;
let imported = 0 ;
let skipped = 0 ;
let filesSeen = 0 ;
const now = new Date (). toISOString ();
for ( const handle of handles ) {
const dir = join ( rawRoot , handle );
if ( ! existsSync ( dir ) || ! statSync ( dir ). isDirectory ()) continue ;
ctx . db . prepare (
`INSERT INTO mentor_sources (handle, enabled, notes, updated_at) VALUES (?,?,?,?)
ON CONFLICT(handle) DO UPDATE SET updated_at=excluded.updated_at` ,
). run ( handle , 1 , 'Imported from local harvest' , now );
const files = readdirSync ( dir ). filter (( f ) => f . endsWith ( '.md' )). slice ( 0 , maxFiles );
for ( const f of files ) {
if ( filesSeen >= maxFiles ) break ;
filesSeen ++ ;
const path = join ( dir , f );
let md = '' ;
try {
md = readFileSync ( path , 'utf8' );
} catch {
skipped ++ ;
continue ;
}
const draft = extractMentorCallFromMarkdown ( md , { handle , rawPath : path });
if ( ! draft || ! draft . symbol || draft . bias === 'unclear' ) {
skipped ++ ;
continue ;
}
const id = randomUUID ();
try {
const run = ctx . db . prepare ( `
INSERT INTO mentor_calls (
id, owner_id, handle, post_id, posted_at, symbol, bias, claim_type,
entry, invalidation, target, horizon_days, excerpt, extract_confidence,
status, logged_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'draft', ?)
ON CONFLICT(owner_id, handle, post_id) DO NOTHING
` ). run (
id ,
ctx . userId ! ,
draft . handle ,
draft . postId ,
draft . postedAt ,
draft . symbol ,
draft . bias ,
draft . claimType ,
draft . entry ,
draft . invalidation ,
draft . target ,
draft . horizonDays ,
draft . excerpt ,
draft . extractConfidence ,
now ,
) as { changes? : number };
if (( run . changes ?? 0 ) > 0 ) imported ++ ;
else skipped ++ ;
} catch {
skipped ++ ;
}
}
}
return { ok : true as const , imported , skipped , filesSeen , message : null as string | null };
}),
list : protectedProcedure
. input ( z . object ({
handle : z.string (). optional (),
status : z.string (). optional (),
limit : z.number (). int (). min ( 1 ). max ( 200 ). optional (),
}). optional ())
. query ( async ({ ctx , input }) => {
const limit = input ? . limit ?? 50 ;
const handle = input ? . handle ? . replace ( /^@/ , '' );
const status = input ? . status ;
if ( handle && status ) {
return ctx . db . prepare (
`SELECT * FROM mentor_calls WHERE owner_id=? AND handle=? AND status=? ORDER BY logged_at DESC LIMIT ?` ,
). all ( ctx . userId ! , handle , status , limit );
}
if ( handle ) {
return ctx . db . prepare (
`SELECT * FROM mentor_calls WHERE owner_id=? AND handle=? ORDER BY logged_at DESC LIMIT ?` ,
). all ( ctx . userId ! , handle , limit );
}
return ctx . db . prepare (
`SELECT * FROM mentor_calls WHERE owner_id=? ORDER BY logged_at DESC LIMIT ?` ,
). all ( ctx . userId ! , limit );
}),
/** Confirm draft so it can be graded. */
confirm : protectedProcedure
. input ( z . object ({
id : z.string (). min ( 1 ),
entry : z.number (). optional (),
invalidation : z.number (). optional (),
target : z.number (). optional (),
bias : z.enum ([ 'educational_long' , 'educational_short' , 'educational_range' ]). optional (),
horizonDays : z.number (). int (). min ( 1 ). max ( 30 ). optional (),
symbol : z . string (). optional (),
}))
. mutation ( async ({ ctx , input }) => {
const row = ctx . db . prepare (
`SELECT * FROM mentor_calls WHERE id=? AND owner_id=?` ,
). get ( input . id , ctx . userId ! ) as Record < string , unknown > | undefined ;
if ( ! row ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Mentor call not found.' });
ctx . db . prepare ( `
UPDATE mentor_calls SET
status='confirmed',
entry=COALESCE(?, entry),
invalidation=COALESCE(?, invalidation),
target=COALESCE(?, target),
bias=COALESCE(?, bias),
horizon_days=COALESCE(?, horizon_days),
symbol=COALESCE(?, symbol)
WHERE id=? AND owner_id=?
` ). run (
input . entry ?? null ,
input . invalidation ?? null ,
input . target ?? null ,
input . bias ?? null ,
input . horizonDays ?? null ,
input . symbol ? . toUpperCase () ?? null ,
input . id ,
ctx . userId ! ,
);
return { ok : true as const };
}),
discard : protectedProcedure
. input ( z . object ({ id : z.string (). min ( 1 ) }))
. mutation ( async ({ ctx , input }) => {
ctx . db . prepare (
`UPDATE mentor_calls SET status='discarded' WHERE id=? AND owner_id=?` ,
). run ( input . id , ctx . userId ! );
return { ok : true as const };
}),
grade : protectedProcedure
. input ( z . object ({ id : z.string (). min ( 1 ) }))
. mutation ( async ({ ctx , input }) => {
const { gradeStudySetup } = await import ( '../analysis/dealerStudyEngine.ts' );
const row = ctx . db . prepare (
`SELECT * FROM mentor_calls WHERE id=? AND owner_id=?` ,
). get ( input . id , ctx . userId ! ) as {
id : string ;
symbol : string | null ;
bias : 'educational_long' | 'educational_short' | 'educational_range' ;
entry : number ;
invalidation : number ;
target : number ;
horizon_days : number ;
posted_at : string | null ;
logged_at : string ;
status : string ;
} | undefined ;
if ( ! row ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Mentor call not found.' });
if ( row . status === 'discarded' ) {
throw new TRPCError ({ code : 'BAD_REQUEST' , message : 'Call was discarded.' });
}
if ( ! row . symbol ) {
throw new TRPCError ({ code : 'BAD_REQUEST' , message : 'Symbol required before grade.' });
}
const loggedAt = row . posted_at && row . posted_at . length >= 10
? ( row . posted_at . includes ( 'T' ) ? row . posted_at : ` ${ row . posted_at } T00:00:00.000Z` )
: row . logged_at ;
try {
2026-08-11 22:25:17 -04:00
await ctx . cache . bumpToWatched ( row . symbol , 'equity' );
2026-08-10 13:36:26 -04:00
} catch { /* optional */ }
const candles = ctx . db . prepare (
`SELECT ts,o,h,l,c FROM price_candles WHERE symbol=? AND timeframe='1d' ORDER BY ts ASC` ,
). all ( row . symbol ) as Array < { ts : string ; o : number ; h : number ; l : number ; c : number } > ;
const result = gradeStudySetup (
{
bias : row.bias ,
entry : row.entry ,
invalidation : row.invalidation ,
target : row.target ,
horizonDays : row.horizon_days ,
loggedAt ,
},
candles ,
);
const status = result . grade === 'incomplete' ? 'confirmed' : 'graded' ;
ctx . db . prepare ( `
UPDATE mentor_calls
SET status=?, grade=?, score=?, grade_summary=?, grade_path=?, graded_at=?
WHERE id=? AND owner_id=?
` ). run (
status ,
result . grade ,
result . score ,
result . summary ,
JSON . stringify ( result . pathNotes ),
result . gradedAt ,
row . id ,
ctx . userId ! ,
);
return { ok : true as const , ... result , status };
}),
gradeDue : protectedProcedure.mutation ( async ({ ctx }) => {
const { gradeStudySetup } = await import ( '../analysis/dealerStudyEngine.ts' );
const open = ctx . db . prepare (
`SELECT * FROM mentor_calls WHERE owner_id=? AND status IN ('confirmed','graded') AND (grade IS NULL OR grade='incomplete')` ,
). all ( ctx . userId ! ) as Array < {
id : string ;
symbol : string | null ;
bias : 'educational_long' | 'educational_short' | 'educational_range' ;
entry : number ;
invalidation : number ;
target : number ;
horizon_days : number ;
posted_at : string | null ;
logged_at : string ;
} > ;
let graded = 0 ;
let incomplete = 0 ;
for ( const row of open ) {
if ( ! row . symbol ) {
incomplete ++ ;
continue ;
}
const loggedAt = row . posted_at && row . posted_at . length >= 10
? ( row . posted_at . includes ( 'T' ) ? row . posted_at : ` ${ row . posted_at . slice ( 0 , 10 ) } T00:00:00.000Z` )
: row . logged_at ;
const candles = ctx . db . prepare (
`SELECT ts,o,h,l,c FROM price_candles WHERE symbol=? AND timeframe='1d' ORDER BY ts ASC` ,
). all ( row . symbol ) as Array < { ts : string ; o : number ; h : number ; l : number ; c : number } > ;
const result = gradeStudySetup (
{
bias : row.bias ,
entry : row.entry ,
invalidation : row.invalidation ,
target : row.target ,
horizonDays : row.horizon_days ,
loggedAt ,
},
candles ,
);
const status = result . grade === 'incomplete' ? 'confirmed' : 'graded' ;
if ( status === 'graded' ) graded ++ ;
else incomplete ++ ;
ctx . db . prepare ( `
UPDATE mentor_calls
SET status=?, grade=?, score=?, grade_summary=?, grade_path=?, graded_at=?
WHERE id=? AND owner_id=?
` ). run (
status ,
result . grade ,
result . score ,
result . summary ,
JSON . stringify ( result . pathNotes ),
result . gradedAt ,
row . id ,
ctx . userId ! ,
);
}
return { ok : true as const , considered : open.length , graded , incomplete };
}),
scorecard : protectedProcedure
. input ( z . object ({ handle : z.string (). optional () }). optional ())
. query ( async ({ ctx , input }) => {
const handle = input ? . handle ? . replace ( /^@/ , '' );
const rows = handle
? ctx . db . prepare (
`SELECT handle, claim_type, grade, score FROM mentor_calls WHERE owner_id=? AND handle=?` ,
). all ( ctx . userId ! , handle )
: ctx . db . prepare (
`SELECT handle, claim_type, grade, score FROM mentor_calls WHERE owner_id=?` ,
). all ( ctx . userId ! );
type Agg = {
key : string ;
handle : string ;
claimType : string ;
n : number ;
wins : number ;
losses : number ;
expired : number ;
incomplete : number ;
scoreSum : number ;
scoreN : number ;
};
const map = new Map < string , Agg >();
for ( const r of rows as Array < { handle : string ; claim_type : string ; grade : string | null ; score : number | null } > ) {
const key = ` ${ r . handle } :: ${ r . claim_type } ` ;
const a = map . get ( key ) ?? {
key ,
handle : r.handle ,
claimType : r.claim_type ,
n : 0 ,
wins : 0 ,
losses : 0 ,
expired : 0 ,
incomplete : 0 ,
scoreSum : 0 ,
scoreN : 0 ,
};
a . n ++ ;
if ( r . grade === 'win' ) a . wins ++ ;
else if ( r . grade === 'loss' ) a . losses ++ ;
else if ( r . grade === 'expired' ) a . expired ++ ;
else a . incomplete ++ ;
if ( typeof r . score === 'number' ) {
a . scoreSum += r . score ;
a . scoreN ++ ;
}
map . set ( key , a );
}
return [... map . values ()]. map (( a ) => {
const decided = a . wins + a . losses ;
return {
handle : a.handle ,
claimType : a.claimType ,
n : a.n ,
wins : a.wins ,
losses : a.losses ,
expired : a.expired ,
incomplete : a.incomplete ,
winRate : decided > 0 ? a . wins / decided : null ,
avgScore : a.scoreN > 0 ? a . scoreSum / a.scoreN : null ,
};
}). sort (( x , y ) => y . n - x . n );
}),
});
// ─── Per-user OpenAI-compatible LLM endpoint ───────────────────────────────
const userLlmRouter = router ({
status : protectedProcedure.query ( async ({ ctx }) => {
const { getUserLlmStatus } = await import ( '../llm/userLlmEndpoint.ts' );
return getUserLlmStatus ( ctx . db , ctx . userId ! );
}),
upsertEndpoint : protectedProcedure
. input ( z . object ({
baseUrl : z.string (). min ( 1 ),
apiKey : z.string (). optional (),
model : z.string (). optional (),
keepExistingKey : z.boolean (). optional (),
}))
. mutation ( async ({ ctx , input }) => {
const { upsertUserLlmEndpoint } = await import ( '../llm/userLlmEndpoint.ts' );
return upsertUserLlmEndpoint ( ctx . db , ctx . userId ! , {
baseUrl : input.baseUrl ,
apiKey : input.apiKey ,
model : input.model ,
keepExistingKey : input.keepExistingKey ?? true ,
});
}),
clear : protectedProcedure.mutation ( async ({ ctx }) => {
const { clearUserLlmEndpoint } = await import ( '../llm/userLlmEndpoint.ts' );
clearUserLlmEndpoint ( ctx . db , ctx . userId ! );
return { ok : true as const };
}),
test : protectedProcedure
. input ( z . object ({
baseUrl : z.string (). min ( 1 ). optional (),
apiKey : z.string (). optional (),
model : z.string (). optional (),
}). optional ())
. mutation ( async ({ ctx , input }) => {
const { loadUserLlmSecret , getUserLlmStatus } = await import ( '../llm/userLlmEndpoint.ts' );
const { testEndpoint } = await import ( '../llm/openaiCompatible.ts' );
const saved = loadUserLlmSecret ( ctx . db , ctx . userId ! );
const baseUrl = input ? . baseUrl ? . trim () || saved ? . baseUrl ;
if ( ! baseUrl ) return { ok : false as const , error : 'No base URL configured' };
const apiKey = input ? . apiKey !== undefined && input . apiKey !== ''
? input.apiKey
: saved?.apiKey ?? null ;
const model = input ? . model ? . trim () || saved ? . model || 'gpt-4o-mini' ;
const result = await testEndpoint ({ baseUrl , apiKey , model });
if ( result . ok ) {
// touch status read for UI
getUserLlmStatus ( ctx . db , ctx . userId ! );
}
return result ;
}),
});
2026-07-05 08:50:12 -04:00
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 };
}),
2026-08-10 13:36:26 -04:00
listPresets : protectedProcedure.query ( async ({ ctx }) => {
return ctx . db . prepare ( 'SELECT * FROM strategy_presets ORDER BY created_at ASC' ). all ();
}),
get : protectedProcedure
. input ( z . object ({ id : z.string () }))
. query ( async ({ ctx , input }) => {
const userId = ctx . userId as string ;
const row = ctx . db . prepare ( 'SELECT * FROM strategies WHERE id=? AND owner_id=?' ). get ( input . id , userId );
if ( ! row ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Strategy not found.' });
return row ;
}),
getPreset : protectedProcedure
. input ( z . object ({ id : z.string () }))
. query ( async ({ ctx , input }) => {
const preset = ctx . db . prepare ( 'SELECT * FROM strategy_presets WHERE id=?' ). get ( input . id );
if ( ! preset ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Preset not found.' });
return preset ;
}),
forkPreset : protectedProcedure
. input ( z . object ({ presetId : z.string () }))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId as string ;
const preset = ctx . db . prepare ( 'SELECT * FROM strategy_presets WHERE id=?' ). get ( input . presetId ) as Record < string , unknown > | undefined ;
if ( ! preset ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Preset not found.' });
const id = randomUUID ();
const now = new Date (). toISOString ();
ctx . db . prepare ( `
INSERT INTO strategies (id, owner_id, name, preset_id, scope, regime_gate, setup, components, exit_profile, risk_policy, is_public, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,0,?)
` ). run ( id , userId , preset . name , preset . id , preset . scope , 'any' , preset . entry_rules , preset . entry_rules , preset . exit_profile , preset . risk_policy , now );
return { id , name : preset.name };
}),
suggestTickers : protectedProcedure
. input ( z . object ({ presetId : z.string () }))
. query ( async ({ ctx , input }) => {
const preset = ctx . db . prepare ( 'SELECT * FROM strategy_presets WHERE id=?' ). get ( input . presetId ) as Record < string , unknown > | undefined ;
if ( ! preset ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Preset not found.' });
// Load available data for live suggestions
const rotationData = ctx . db . prepare ( `
SELECT symbol, kind, rs_1m, rs_1w, leadership FROM rotation_rank_snapshots
WHERE date = (SELECT MAX(date) FROM rotation_rank_snapshots)
ORDER BY rs_1m DESC
` ). all () as Array < { symbol : string ; kind : string ; rs_1m : number | null ; rs_1w : number | null ; leadership : string } > ;
const symbolsData = ctx . db . prepare ( 'SELECT symbol, name, sector, ticker_kind FROM symbols' ). all () as Array < { symbol : string ; name : string | null ; sector : string | null ; ticker_kind : string } > ;
const symbolsMap = new Map ( symbolsData . map ( s => [ s . symbol , s ]));
const dividendData = ctx . db . prepare ( 'SELECT symbol, dividend_yield, payout_ratio, growth_streak_years FROM dividend_fundamentals' ). all () as Array < { symbol : string ; dividend_yield : number | null ; payout_ratio : number | null ; growth_streak_years : number | null } > ;
const dividendMap = new Map ( dividendData . map ( d => [ d . symbol , d ]));
// Helper: get symbol name from map or return null
const nameOf = ( s : string ) : string | null => symbolsMap . get ( s ) ? . name ?? null ;
// Helper: get tickers in leading rotation sectors
const leadingSectors = new Set ( rotationData . filter ( r => r . leadership === 'leading' ). map ( r => r . symbol ));
const rotatedSymbols = rotationData . map ( r => r . symbol );
// Helper: find equities in a sector from symbols table (limited to the 102 we track)
const stocksInSectors = ( sectors : string []) : string [] =>
symbolsData . filter ( s => s . ticker_kind === 'equity' && s . sector && sectors . some ( sec => s . sector ! . includes ( sec ))). map ( s => s . symbol );
interface Suggestion { symbol : string ; name : string | null ; role : string ; allocationPct : number ; reason : string ; source : 'live' | 'default' ; }
let suggestions : Suggestion [] = [];
switch ( input . presetId ) {
case 'dca_accumulation' : {
// Top ETFs + top large-cap stocks from rotation leaders
const etfs = symbolsData . filter ( s => s . ticker_kind === 'etf' ). slice ( 0 , 3 );
suggestions = etfs . map (( e , i ) => ({
symbol : e . symbol , name : e.name , role : 'Core holding' ,
allocationPct : [ 60 , 25 , 15 ][ i ] ?? 10 ,
reason : 'Broad market ETF - low cost diversification' ,
source : 'live' as const ,
}));
if ( suggestions . length === 0 ) {
suggestions = [
{ symbol : 'VOO' , name : 'S&P 500 ETF' , role : 'Core holding' , allocationPct : 60 , reason : 'Broad market low-cost ETF' , source : 'default' },
{ symbol : 'IVV' , name : 'S&P 500 ETF (iShares)' , role : 'Core holding' , allocationPct : 40 , reason : 'Alternative broad market ETF' , source : 'default' },
];
}
break ;
}
case 'dividend_growth' : {
// Live: query dividend_fundamentals for yield >= 2%, payout <= 60%
const candidates = dividendData . filter ( d =>
d . dividend_yield != null && d . dividend_yield >= 2 &&
d . payout_ratio != null && d . payout_ratio <= 60
). sort (( a , b ) => ( b . growth_streak_years ?? 0 ) - ( a . growth_streak_years ?? 0 )). slice ( 0 , 4 );
if ( candidates . length > 0 ) {
const pcts = [ 40 , 25 , 20 , 15 ];
suggestions = candidates . map (( c , i ) => ({
symbol : c . symbol , name : nameOf ( c . symbol ), role : 'Dividend growth' ,
allocationPct : pcts [ i ] ?? 10 ,
reason : c.growth_streak_years ? ` ${ c . growth_streak_years } + year dividend growth streak` : 'Stable dividend payer' ,
source : 'live' as const ,
}));
} else {
suggestions = [
{ symbol : 'SCHD' , name : 'Schwab US Dividend Equity ETF' , role : 'Dividend growth' , allocationPct : 40 , reason : 'Strong dividend growth history' , source : 'default' },
{ symbol : 'JNJ' , name : 'Johnson & Johnson' , role : 'Dividend growth' , allocationPct : 20 , reason : '60+ years of dividend growth' , source : 'default' },
{ symbol : 'KO' , name : 'Coca-Cola' , role : 'Dividend growth' , allocationPct : 20 , reason : '60+ years of dividend growth' , source : 'default' },
{ symbol : 'PG' , name : 'Procter & Gamble' , role : 'Dividend growth' , allocationPct : 20 , reason : '130+ years of dividends' , source : 'default' },
];
}
break ;
}
case 'core_satellite' : {
// Core: fixed ETFs. Satellite: top rotation ETFs + top stock in leading sector.
const coreEtfs = symbolsData . filter ( s => [ 'VOO' , 'VTI' , 'IVV' , 'VXUS' ]. includes ( s . symbol ));
suggestions = coreEtfs . length > 0 ? coreEtfs . slice ( 0 , 2 ). map (( e , i ) => ({
symbol : e . symbol , name : e.name , role : 'Core' , allocationPct : [ 50 , 20 ][ i ] ?? 15 ,
reason : i === 0 ? 'Broad market core holding' : 'International diversification' , source : 'live' as const ,
})) : [
{ symbol : 'VOO' , name : 'S&P 500 ETF' , role : 'Core' , allocationPct : 50 , reason : 'Broad market core holding' , source : 'default' },
{ symbol : 'VXUS' , name : 'Total International Stock ETF' , role : 'Core' , allocationPct : 20 , reason : 'International diversification' , source : 'default' },
];
// Satellite: top ETF from rotation leaders
const topRotated = rotationData . filter ( r => r . leadership === 'leading' || r . rs_1m != null ). slice ( 0 , 2 );
suggestions . push (... topRotated . map (( r , i ) => ({
symbol : r . symbol , name : nameOf ( r . symbol ) ?? r . symbol , role : i === 0 ? 'Satellite - Growth' : 'Satellite - Thematic' ,
allocationPct : [ 15 , 15 ][ i ] ?? 10 ,
reason : r.rs_1m ? `Sector momentum: 1M RS ${ r . rs_1m > 0 ? '+' : '' }${ r . rs_1m . toFixed ( 1 ) } pp` : 'Rotation leader' ,
source : 'live' as const ,
})));
if ( suggestions . length < 4 ) {
suggestions . push ({ symbol : 'QQQ' , name : 'Nasdaq 100 ETF' , role : 'Satellite - Growth' , allocationPct : 15 , reason : 'Tech-heavy growth exposure' , source : 'default' });
}
break ;
}
case 'barbell_strategy' : {
// Safe core + bond ETF + top growth stocks from rotation
suggestions = [
{ symbol : 'VOO' , name : nameOf ( 'VOO' ) ?? 'S&P 500 ETF' , role : 'Safe core' , allocationPct : 50 , reason : 'Broad market core' , source : 'live' as const },
{ symbol : 'BND' , name : nameOf ( 'BND' ) ?? 'Total Bond Market ETF' , role : 'Safe bonds' , allocationPct : 30 , reason : 'Bond diversification' , source : 'live' as const },
];
// Growth picks: top equities from symbols in leading sectors, or fallback
const growthPicks = symbolsData . filter ( s =>
s . ticker_kind === 'equity' && s . sector && leadingSectors . size > 0 &&
Array . from ( leadingSectors ). some ( ls => s . sector ! . includes ( ls . replace ( ' ' , '/' )))
). slice ( 0 , 2 );
if ( growthPicks . length >= 2 ) {
suggestions . push ({ symbol : growthPicks [ 0 ]. symbol , name : growthPicks [ 0 ]. name , role : 'Growth bet' , allocationPct : 10 , reason : 'Leading sector momentum' , source : 'live' });
suggestions . push ({ symbol : growthPicks [ 1 ]. symbol , name : growthPicks [ 1 ]. name , role : 'Growth bet' , allocationPct : 10 , reason : 'Sector rotation leader' , source : 'live' });
} else {
suggestions . push ({ symbol : 'NVDA' , name : 'NVIDIA Corporation' , role : 'Growth bet' , allocationPct : 10 , reason : 'High-growth tech leader' , source : 'default' });
suggestions . push ({ symbol : 'AMZN' , name : 'Amazon.com Inc' , role : 'Growth bet' , allocationPct : 10 , reason : 'High-growth consumer/cloud leader' , source : 'default' });
}
break ;
}
case 'covered_call_wheel' : {
// Large-cap liquid stocks with active options
const liquidStocks = symbolsData . filter ( s => s . ticker_kind === 'equity' && [ 'AAPL' , 'MSFT' , 'NVDA' , 'AMZN' , 'GOOGL' , 'META' , 'TSLA' ]. includes ( s . symbol )). slice ( 0 , 4 );
if ( liquidStocks . length >= 4 ) {
const pcts = [ 30 , 30 , 20 , 20 ];
suggestions = liquidStocks . map (( s , i ) => ({
symbol : s . symbol , name : s.name , role : 'Covered call' ,
allocationPct : pcts [ i ] ?? 15 ,
reason : 'High liquidity, strong options market' , source : 'live' as const ,
}));
} else {
suggestions = [
{ symbol : 'AAPL' , name : 'Apple Inc.' , role : 'Covered call' , allocationPct : 30 , reason : 'High liquidity, strong options market' , source : 'default' },
{ symbol : 'MSFT' , name : 'Microsoft Corporation' , role : 'Covered call' , allocationPct : 30 , reason : 'High liquidity, stable price' , source : 'default' },
{ symbol : 'NVDA' , name : 'NVIDIA Corporation' , role : 'Covered call' , allocationPct : 20 , reason : 'Active options chain' , source : 'default' },
{ symbol : 'AMZN' , name : 'Amazon.com Inc' , role : 'Covered call' , allocationPct : 20 , reason : 'High liquidity options market' , source : 'default' },
];
}
break ;
}
default : {
// Fallback: use example_tickers from preset
const examples = preset . example_tickers as string | null ;
if ( examples ) {
try { suggestions = JSON . parse ( examples ); } catch { suggestions = []; }
}
}
}
return suggestions ;
}),
2026-07-05 08:50:12 -04:00
});
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 []);
}),
2026-08-10 13:36:26 -04:00
runPortfolio : protectedProcedure
. input ( z . object ({
allocations : z.array ( z . object ({ symbol : z . string (), targetPct : z.number () })),
rebalanceFreq : z.enum ([ 'none' , 'quarterly' , 'annual' ]). default ( 'quarterly' ),
driftThreshold : z.number (). default ( 5 ),
}))
. query ( async ({ ctx , input }) => {
const { PortfolioBacktestEngine } = await import ( '../strategy/PortfolioBacktestEngine.ts' );
const candles : Record < string , unknown [] > = {};
for ( const a of input . allocations ) {
const entry = await ctx . cache . get < unknown [] >( `yfinance:candles: ${ a . symbol } :1d` );
candles [ a . symbol ] = entry . value ?? [];
}
const engine = new PortfolioBacktestEngine ();
return engine . run ({
allocations : input.allocations ,
candles ,
rebalanceFreq : input.rebalanceFreq ,
driftThreshold : input.driftThreshold ,
});
}),
2026-07-05 08:50:12 -04:00
});
// ─── Sector Crosslink Router (Slice 14) ─────────────────────────────────────
const sectorCrosslinkRouter = router ({
confirm : protectedProcedure
. input ( z . object ({ strategyId : z.string (), sector : z.string () }))
. query ( async ({ ctx , input }) => {
const { crossLinkSector } = await import ( '../screener/SectorCrosslink.ts' );
const strat = ctx . db . prepare ( 'SELECT * FROM strategies WHERE id=? AND owner_id=?' ). get ( input . strategyId , ctx . userId ) as { components : string } | undefined ;
if ( ! strat ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Strategy not found.' });
const components = JSON . parse ( strat . components ) as Array < { type : string ; conditions? : string [] } > ;
const setup = components . find (( c ) => c . type === 'setup' );
const conditions = setup ? . conditions ?? [];
// Get rotation signals from DB if available
const signals = ctx . db . prepare ( 'SELECT * FROM rotation_signals ORDER BY ts DESC LIMIT 10' ). all () as Array < Record < string , unknown >>;
const rotationSignals = signals . map (( s ) => ({
fromSector : String ( s . from_sector ?? '' ),
toSector : String ( s . to_sector ?? s . sector ?? '' ),
confidence : Number ( s . confidence ?? 50 ),
date : String ( s . ts ?? '' ),
}));
const universe : any [] = [];
return crossLinkSector ( input . strategyId , input . sector , conditions , universe , rotationSignals );
}),
});
// ─── Derisking Router (Slice 23) ────────────────────────────────────────────
const deriskingRouter = router ({
suggest : protectedProcedure
. input ( z . object ({
symbol : z . string (),
currentPrice : z.number (),
avgCost : z.number (),
shares : z.number (),
ema21 : z.number (). optional (),
ema50 : z.number (). optional (),
thesisStatus : z.enum ([ 'intact' , 'weakening' , 'broken' ]). optional (),
currentRegime : z.enum ([ 'trending-up' , 'trending-down' , 'range-bound' ]). optional (),
portfolioCorrelation : z.number (). optional (),
profitTargets : z.array ( z . number ()). optional (),
2026-08-10 13:36:26 -04:00
dividendYield : z.number (). optional (),
payoutRatio : z.number (). optional (),
2026-07-05 08:50:12 -04:00
}))
. query ( async ({ ctx , input }) => {
const { suggestDerisking } = await import ( '../derisking/DeriskingEngine.ts' );
return { suggestions : suggestDerisking ( input ) };
}),
2026-08-10 13:36:26 -04:00
dividendHealth : protectedProcedure
. input ( z . object ({ symbol : z . string () }))
. query ( async ({ ctx , input }) => {
const entry = await ctx . cache . get < Record < string , unknown >>( `yfinance:dividendFundamentals: ${ input . symbol } ` );
return { data : entry.value };
}),
2026-07-05 08:50:12 -04:00
});
// ─── 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 ,
2026-08-10 13:36:26 -04:00
footer : 'Educational observation only.' ,
2026-07-23 18:02:24 -04:00
};
}),
});
2026-08-10 13:36:26 -04:00
// ─── Risk Posture Router ────────────────────────────────────────────────────
2026-07-23 18:02:24 -04:00
const riskRouter = router ({
2026-08-10 13:36:26 -04:00
/** Simplified risk posture: holdings, drawdown vs tolerance, basic metrics. */
2026-07-23 18:02:24 -04:00
posture : protectedProcedure
. input ( z . object ({
equity : z.number (). positive (),
peakEquity : z.number (). positive (). optional (),
drawdownTolerancePct : z.number (). positive (). max ( 100 ). optional (),
regime : z.enum ([ 'trending-up' , 'trending-down' , 'range-bound' ]). optional (),
plans : z.array ( z . object ({
symbol : z . string (),
stopPrice : z.number (). optional (),
rewardTarget : z.number (). optional (),
cluster : z.string (). optional (),
})). optional (),
}))
. query ( async ({ ctx , input }) => {
const { assessRisk , ADR_0007_FOOTER } = await import ( '../risk/RiskEngine.ts' );
const { listHoldings } = await import ( '../db/portfolioRepository.ts' );
2026-08-10 13:36:26 -04:00
const { listOptionLegs } = await import ( '../db/portfolioOptionRepository.ts' );
const {
assessOptionRiskContribution ,
mergeOptionCapitalIntoClusters ,
} = await import ( '../risk/optionRiskContribution.ts' );
2026-07-23 18:02:24 -04:00
const userId = ctx . userId as string ;
const user = ctx . db . prepare (
'SELECT complexity, drawdown_tolerance FROM users WHERE id=?' ,
). get ( userId ) as { complexity : string ; drawdown_tolerance : number | null } | undefined ;
const complexity = ( user ? . complexity ?? 'beginner' ) as
'beginner' | 'intermediate' | 'advanced' ;
const drawdownTolerancePct = input . drawdownTolerancePct
?? ( typeof user ? . drawdown_tolerance === 'number'
? Math . abs ( user . drawdown_tolerance )
: 20 );
const planBySymbol = new Map (
( input . plans ?? []). map (( p ) => [ p . symbol . toUpperCase (), p ]),
);
const holdings = listHoldings ( ctx . db , userId );
2026-08-10 13:36:26 -04:00
const optionLegs = listOptionLegs ( ctx . db , userId );
2026-07-23 18:02:24 -04:00
const portfolio = holdings . map (( h ) => {
const plan = planBySymbol . get ( h . symbol . toUpperCase ());
return {
symbol : h . symbol ,
shares : h.shares ,
avgCost : h.avg_cost ,
cluster : plan?.cluster ?? 'uncategorized' ,
stopPrice : plan?.stopPrice ,
rewardTarget : plan?.rewardTarget ,
};
});
const equity = input . equity ;
const peakEquity = input . peakEquity ?? equity ;
const defaultCap = complexity === 'beginner' ? equity * 0.25
: complexity === 'intermediate' ? equity * 0.40
: null ;
const clusterCaps = defaultCap !== null ? { uncategorized : defaultCap } as Record < string , number > : null ;
2026-08-10 13:36:26 -04:00
const optionRisk = assessOptionRiskContribution (
optionLegs ,
holdings . map (( h ) => ({ symbol : h . symbol , shares : h.shares })),
equity ,
);
2026-07-23 18:02:24 -04:00
const posture = assessRisk ({
portfolio ,
account : { equity , drawdownTolerancePct , complexity },
peakEquity ,
regime : input.regime ,
sizingContext : { clusterCaps },
});
2026-08-10 13:36:26 -04:00
const foldUncategorized = complexity === 'beginner' ;
2026-07-23 18:02:24 -04:00
const clusterExposure = mergeOptionCapitalIntoClusters (
posture . clusterExposure ,
optionRisk . capitalByUnderlying ,
2026-08-10 13:36:26 -04:00
foldUncategorized ,
2026-07-23 18:02:24 -04:00
);
2026-08-10 13:36:26 -04:00
const equityExposureUsd = portfolio . reduce (( s , p ) => s + p . shares * p . avgCost , 0 );
const totalExposureUsd = equityExposureUsd + optionRisk . capitalCommittedUsd ;
2026-07-23 18:02:24 -04:00
const recommendedActions = [
... posture . recommendedActions ,
... optionRisk . recommendations ,
];
return {
... posture ,
clusterExposure ,
recommendedActions ,
2026-08-10 13:36:26 -04:00
halted : false ,
halt : null ,
2026-07-23 18:02:24 -04:00
holdingsCount : holdings.length ,
2026-08-10 13:36:26 -04:00
optionLegsCount : optionRisk.legsCount ,
totalExposureUsd ,
optionRisk : {
2026-07-23 18:02:24 -04:00
premiumAtRiskUsd : optionRisk.premiumAtRiskUsd ,
cashReservedUsd : optionRisk.cashReservedUsd ,
creditReceivedUsd : optionRisk.creditReceivedUsd ,
capitalCommittedUsd : optionRisk.capitalCommittedUsd ,
uncoveredShortCallCount : optionRisk.uncoveredShortCallCount ,
},
footer : ADR_0007_FOOTER ,
};
}),
});
// ─── Emotion Logger Router (Slice: emotion-logger-storage) ──────────────────────
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 });
}
}),
});
2026-08-10 13:36:26 -04:00
// ─── Trades Router (Phase 5) ─────────────────────────────────────────────
const tradesRouter = router ({
list : protectedProcedure.query ( async ({ ctx }) => {
return ctx . db . prepare ( 'SELECT * FROM trades WHERE owner_id=? ORDER BY date DESC' ). all ( ctx . userId as string );
}),
get : protectedProcedure
. input ( z . object ({ id : z.string () }))
. query ( async ({ ctx , input }) => {
const row = ctx . db . prepare ( 'SELECT * FROM trades WHERE id=? AND owner_id=?' ). get ( input . id , ctx . userId as string );
if ( ! row ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Trade not found.' });
return row ;
}),
save : protectedProcedure
. input ( z . object ({
id : z.string (),
symbol : z . string (),
date : z.string (),
status : z.string (),
environment : z.string (). optional (),
tier : z.string (),
strategyId : z.string (). optional (),
confluenceScore : z.number (). optional (),
riskPct : z.number (). optional (),
positionSize : z.number (). optional (),
entryPrice : z.number (). optional (),
stopLoss : z.number (). optional (),
targets : z.string (). optional (),
justifications : z.string (). optional (),
checklistCompleted : z.number (). optional (),
allocationPlanId : z.string (). optional (),
allocationRole : z.string (). optional (),
tradeId : z.string (). optional (),
}))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId as string ;
const tradeKey = input . tradeId ?? input . id ;
const existing = ctx . db . prepare ( 'SELECT id FROM trades WHERE id=? AND owner_id=?' ). get ( input . id , userId ) as Record < string , unknown > | undefined ;
if ( existing ) {
ctx . db . prepare ( `
UPDATE trades SET symbol=?,date=?,status=?,environment=?,tier=?,strategy_id=?,
confluence_score=?,risk_pct=?,position_size=?,entry_price=?,stop_loss=?,
targets=?,justifications=?,checklist_completed=?,allocation_plan_id=?,allocation_role=?,trade_id=?
WHERE id=? AND owner_id=?
` ). run (
input . symbol . toUpperCase (), input . date , input . status , input . environment ?? null , input . tier ,
input . strategyId ?? null , input . confluenceScore ?? null , input . riskPct ?? null ,
input . positionSize ?? null , input . entryPrice ?? null , input . stopLoss ?? null ,
input . targets ?? '[]' , input . justifications ?? '{}' , input . checklistCompleted ?? 0 ,
input . allocationPlanId ?? null , input . allocationRole ?? null , tradeKey , input . id , userId
);
} else {
ctx . db . prepare ( `
INSERT INTO trades (id,owner_id,trade_id,symbol,date,status,environment,tier,strategy_id,
confluence_score,risk_pct,position_size,entry_price,stop_loss,targets,justifications,checklist_completed,allocation_plan_id,allocation_role)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
` ). run (
input . id , userId , tradeKey , input . symbol . toUpperCase (), input . date , input . status , input . environment ?? null ,
input . tier , input . strategyId ?? null , input . confluenceScore ?? null , input . riskPct ?? null ,
input . positionSize ?? null , input . entryPrice ?? null , input . stopLoss ?? null ,
input . targets ?? '[]' , input . justifications ?? '{}' , input . checklistCompleted ?? 0 ,
input . allocationPlanId ?? null , input . allocationRole ?? null
);
}
return { ok : true , id : input.id };
}),
close : protectedProcedure
. input ( z . object ({
id : z.string (),
realizedPnl : z.number (),
reflection : z.string (). optional (),
}))
. mutation ( async ({ ctx , input }) => {
const userId = ctx . userId as string ;
const row = ctx . db . prepare ( 'SELECT justifications FROM trades WHERE id=? AND owner_id=?' ). get ( input . id , userId ) as { justifications : string | null } | undefined ;
if ( ! row ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Trade not found.' });
let just : Record < string , unknown > = {};
try { just = JSON . parse ( row . justifications || '{}' ); } catch { /* ignore */ }
if ( input . reflection ? . trim ()) just . closeReflection = input . reflection . trim ();
ctx . db . prepare ( 'UPDATE trades SET status=?, realized_pnl=?, justifications=? WHERE id=? AND owner_id=?' )
. run ( 'closed' , input . realizedPnl , JSON . stringify ( just ), input . id , userId );
return { ok : true };
}),
stats : protectedProcedure.query ( async ({ ctx }) => {
const userId = ctx . userId as string ;
const closed = ctx . db . prepare ( 'SELECT COUNT(*) c FROM trades WHERE owner_id=? AND status=?' ). get ( userId , 'closed' ) as { c : number } | undefined ;
const profitable = ctx . db . prepare ( 'SELECT COUNT(*) c FROM trades WHERE owner_id=? AND status=? AND realized_pnl>0' ). get ( userId , 'closed' ) as { c : number } | undefined ;
return { totalClosed : closed?.c ?? 0 , profitable : profitable?.c ?? 0 };
}),
});
const thesesRouter = router ({
list : protectedProcedure.query (({ ctx }) => {
return ctx . db . prepare (
'SELECT * FROM theses WHERE user_id=? ORDER BY updated_at DESC' ,
). all ( ctx . userId as string );
}),
get : protectedProcedure
. input ( z . object ({ id : z.string () }))
. query (({ ctx , input }) => {
const row = ctx . db . prepare ( 'SELECT * FROM theses WHERE id=? AND user_id=?' ). get ( input . id , ctx . userId as string );
if ( ! row ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Thesis not found.' });
return row ;
}),
create : protectedProcedure
. input ( z . object ({
symbol : z . string (). min ( 1 ),
statement : z.string (). min ( 1 ),
invalidationCriteria : z.array ( z . string ()). default ([]),
status : z.enum ([ 'intact' , 'weakening' , 'broken' ]). default ( 'intact' ),
}))
. mutation (({ ctx , input }) => {
const id = randomUUID ();
const now = new Date (). toISOString ();
ctx . db . prepare (
`INSERT INTO theses (id, user_id, symbol, statement, invalidation_criteria, status, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)` ,
). run (
id ,
ctx . userId as string ,
input . symbol . toUpperCase (),
input . statement ,
JSON . stringify ( input . invalidationCriteria ),
input . status ,
now ,
now ,
);
return { id , symbol : input . symbol . toUpperCase () };
}),
update : protectedProcedure
. input ( z . object ({
id : z.string (),
statement : z.string (). min ( 1 ). optional (),
invalidationCriteria : z.array ( z . string ()). optional (),
status : z.enum ([ 'intact' , 'weakening' , 'broken' ]). optional (),
}))
. mutation (({ ctx , input }) => {
const userId = ctx . userId as string ;
const row = ctx . db . prepare ( 'SELECT * FROM theses WHERE id=? AND user_id=?' ). get ( input . id , userId ) as {
statement : string ;
invalidation_criteria : string ;
status : string ;
} | undefined ;
if ( ! row ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Thesis not found.' });
const statement = input . statement ?? row . statement ;
const criteria = input . invalidationCriteria
? JSON . stringify ( input . invalidationCriteria )
: row . invalidation_criteria ;
const status = input . status ?? row . status ;
ctx . db . prepare (
'UPDATE theses SET statement=?, invalidation_criteria=?, status=?, updated_at=? WHERE id=? AND user_id=?' ,
). run ( statement , criteria , status , new Date (). toISOString (), input . id , userId );
return { ok : true };
}),
delete : protectedProcedure
. input ( z . object ({ id : z.string () }))
. mutation (({ ctx , input }) => {
const info = ctx . db . prepare ( 'DELETE FROM theses WHERE id=? AND user_id=?' ). run ( input . id , ctx . userId as string );
if ( info . changes === 0 ) throw new TRPCError ({ code : 'NOT_FOUND' , message : 'Thesis not found.' });
return { ok : true };
}),
});
// ─── Fund Router (M21 Mirror Portfolio) ─────────────────────────────────────
const symbolsRouter = router ({
/** Autocomplete search over the symbol index (M22). Local-first (ADR-0009). */
search : publicProcedure
. input ( z . object ({
q : z.string (). trim (). min ( 1 ). max ( 60 ),
limit : z.number (). int (). min ( 1 ). max ( 20 ). optional (),
}))
. query ( async ({ ctx , input }) => {
const limit = input . limit ?? 10 ;
const q = input . q . toUpperCase ();
const db = ctx . db ;
// Prefix match on symbol first (exact > prefix, shortest first), then name contains.
const rows = db . prepare (
`SELECT symbol, name, sector, industry, exchange, ticker_kind, cik,
CASE WHEN symbol = ? THEN 0 ELSE 1 END AS rank
FROM symbols
WHERE symbol = ? OR symbol LIKE ?
ORDER BY rank ASC, LENGTH(symbol) ASC, symbol ASC
LIMIT ?` ,
). all ( q , q , ` ${ q } %` , limit ) as Array < Record < string , any >>;
let results = rows . map (( r ) => ({
symbol : r . symbol ,
name : r.name ?? null ,
sector : r.sector ?? null ,
industry : r.industry ?? null ,
exchange : r.exchange ?? null ,
ticker_kind : r.ticker_kind ,
cik : r.cik ?? null ,
}));
if ( results . length < limit ) {
const seen = new Set ( results . map (( r ) => r . symbol ));
const nameRows = db . prepare (
`SELECT symbol, name, sector, industry, exchange, ticker_kind, cik
FROM symbols
WHERE name IS NOT NULL AND name != '' AND name LIKE ?
ORDER BY LENGTH(name) ASC
LIMIT ?` ,
). all ( `% ${ input . q } %` , limit ) as Array < Record < string , any >>;
const fresh = nameRows
. filter (( r ) => ! seen . has ( r . symbol as string ))
. map (( r ) => ({
symbol : r . symbol ,
name : r.name ?? null ,
sector : r.sector ?? null ,
industry : r.industry ?? null ,
exchange : r.exchange ?? null ,
ticker_kind : r.ticker_kind ,
cik : r.cik ?? null ,
}));
results = [... results , ... fresh . slice ( 0 , limit - results . length )];
}
return { q : input.q , results };
}),
/** Two-tier fund-holdings strip for a symbol (M22): tracked funds w/ weight, then institution count. */
holders : publicProcedure
. input ( z . object ({ symbol : z . string (). trim (). toUpperCase (). min ( 1 ). max ( 20 ) }))
. query ( async ({ ctx , input }) => {
const db = ctx . db ;
const symbol = input . symbol ;
// Tier 1 — tracked funds holding the symbol (most recent record per fund), with weight in their disclosed book.
const trackedRows = db . prepare (
`SELECT tf.id AS fund_id, tf.fund_name, fpr.symbol, fpr.shares, fpr.value_usd, fpr.as_of, fpr.source
FROM fund_position_records fpr
JOIN tracked_funds tf ON tf.id = fpr.fund_id AND tf.enabled = 1
JOIN (
SELECT fund_id, MAX(as_of) AS max_as_of
FROM fund_position_records
WHERE symbol = ?
GROUP BY fund_id
) latest ON latest.fund_id = fpr.fund_id AND latest.max_as_of = fpr.as_of
WHERE fpr.symbol = ?
ORDER BY tf.fund_name ASC` ,
). all ( symbol , symbol ) as Array < Record < string , any >>;
// Weight = position value / total disclosed book value at the fund's live book (per fund).
const fundIds = [... new Set ( trackedRows . map (( r ) => r . fund_id as string ))];
const bookValueByFund = new Map < string , number >();
for ( const fid of fundIds ) {
const { liveBook } = await import ( '../db/fundRepository.ts' );
const book = liveBook ( db , fid );
const total = book . reduce (( s , p ) => s + ( p . value_usd ?? 0 ), 0 );
bookValueByFund . set ( fid , total > 0 ? total : 0 );
}
const tracked = trackedRows . map (( r ) => ({
fundId : r.fund_id ,
fundName : r.fund_name ,
shares : r.shares ?? null ,
valueUsd : r.value_usd ?? null ,
asOf : r.as_of ,
source : r.source ,
weightPct : bookValueByFund.get ( r . fund_id )
? (( r . value_usd ?? 0 ) / bookValueByFund . get ( r . fund_id ) ! ) * 100
: null ,
}));
// Tier 2 — how many distinct 13F filers reported holding the symbol.
const instRow = db . prepare (
`SELECT COUNT(DISTINCT filer_cik) AS n FROM institution_filings WHERE symbol = ?` ,
). get ( symbol ) as { n : number } | undefined ;
return {
symbol ,
tracked ,
institutionCount : instRow?.n ?? 0 ,
};
}),
});
const fundsRouter = router ({
/** List operator-curated tracked funds (v1: Alpine Fox). */
list : publicProcedure
. query ( async ({ ctx }) => {
const { listTrackedFunds } = await import ( '../db/fundRepository.ts' );
return listTrackedFunds ( ctx . db , { includeDisabled : true });
}),
/** Get a single tracked fund by id. */
get : publicProcedure
. input ( z . object ({ id : z.string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const { getTrackedFund } = await import ( '../db/fundRepository.ts' );
return getTrackedFund ( ctx . db , input . id );
}),
/** Live Book for a fund — most recent record per symbol, source-labeled. */
liveBook : publicProcedure
. input ( z . object ({ fundId : z.string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const { liveBook } = await import ( '../db/fundRepository.ts' );
return liveBook ( ctx . db , input . fundId );
}),
/** Full append-only position timeline for a fund. */
records : publicProcedure
. input ( z . object ({ fundId : z.string (). min ( 1 ) }))
. query ( async ({ ctx , input }) => {
const { listRecords } = await import ( '../db/fundRepository.ts' );
return listRecords ( ctx . db , input . fundId );
}),
/** Admin CRUD — operator-curated registry. */
adminCreate : adminProcedure
. input ( z . object ({
ci_key : z.string (). min ( 1 ),
fund_name : z.string (). min ( 1 ),
manager_name : z.string (). min ( 1 ),
x_handle : z.string (). optional (),
paywall_status : z.enum ([ 'unknown' , 'open' , 'paywalled' ]). optional (),
enabled : z.boolean (). optional (),
}))
. mutation ( async ({ ctx , input }) => {
const { upsertTrackedFund } = await import ( '../db/fundRepository.ts' );
return upsertTrackedFund ( ctx . db , input );
}),
adminSetEnabled : adminProcedure
. input ( z . object ({ id : z.string (). min ( 1 ), enabled : z.boolean () }))
. mutation ( async ({ ctx , input }) => {
const { setFundEnabled } = await import ( '../db/fundRepository.ts' );
return { updated : setFundEnabled ( ctx . db , input . id , input . enabled ) };
}),
adminDelete : adminProcedure
. input ( z . object ({ id : z.string (). min ( 1 ) }))
. mutation ( async ({ ctx , input }) => {
const { deleteTrackedFund } = await import ( '../db/fundRepository.ts' );
return { deleted : deleteTrackedFund ( ctx . db , input . id ) };
}),
/** Fetch the fund's full 13F history + refresh Live-Book records. */
adminSync13f : protectedProcedure
. input ( z . object ({ fundId : z.string (). min ( 1 ) }))
. mutation ( async ({ ctx , input }) => {
const { fetchFund13F } = await import ( '../mirror/fund13fFetcher.ts' );
return fetchFund13F ( ctx . db , input . fundId );
}),
/** Materialize position captures from the fund manager's X posts (M21). */
adminIngestCaptures : protectedProcedure
. input ( z . object ({ fundId : z.string (). min ( 1 ) }))
. mutation ( async ({ ctx , input }) => {
const { ingestFundCaptures } = await import ( '../services/captureIngest.ts' );
return ingestFundCaptures ( ctx . db , input . fundId );
}),
});
// ─── Mirror Router (M21) — math, not advice ────────────────────────────────
const mirrorRouter = router ({
/**
* Mirror diff: target vs the user's actual book. Pure engine output; all
* strings are mechanical (ADR-0010). Base defaults to live portfolio equity.
*/
diff : protectedProcedure
. input ( z . object ({
fundId : z.string (). min ( 1 ),
base : z.number (). positive (). optional (), // if omitted, use live equity
locked : z.boolean (). optional (),
minPositionUsd : z.number (). optional (),
minWeightPct : z.number (). optional (),
tickSize : z.number (). optional (),
}))
. query ( async ({ ctx , input }) => {
const userId = ctx . userId ?? 'anonymous' ;
const { liveBook } = await import ( '../db/fundRepository.ts' );
const { listHoldings } = await import ( '../db/portfolioRepository.ts' );
const { mirrorBook } = await import ( '../mirror/mirrorEngine.ts' );
const book = liveBook ( ctx . db , input . fundId );
const holdings = listHoldings ( ctx . db , userId );
const symbols = Array . from ( new Set ([... book . map (( p ) => p . symbol ), ... holdings . map (( h ) => h . symbol )]));
const entries = await ctx . cache . getMany < unknown >( symbols . map (( s ) => `yfinance:quote: ${ s } ` ));
const prices : Record < string , number | undefined > = {};
symbols . forEach (( s , i ) => {
const q = ( entries [ i ] ? . value ?? null ) as { regularMarketPrice? : number ; price? : number } | null ;
prices [ s ] = q ? . regularMarketPrice ?? q ? . price ;
});
// Actual portfolio equity (sum of market value) for re-baseline default.
let equity = 0 ;
for ( const h of holdings ) {
const px = prices [ h . symbol ];
if ( typeof px === 'number' ) equity += px * h . qty ;
}
const base = input . base ?? ( equity > 0 ? equity : 200_000 );
return mirrorBook (
book ,
holdings . map (( h ) => ({ symbol : h . symbol , qty : h.shares , avg_cost : h.avg_cost })),
prices ,
{
base ,
locked : input.locked ?? false ,
minPositionUsd : input.minPositionUsd ,
minWeightPct : input.minWeightPct ,
tickSize : input.tickSize ,
},
);
}),
});
2026-08-10 16:07:19 -04:00
// ─── Confluence Signal Engine (M22) ─────────────────────────────────────────
const confluenceRouter = router ({
2026-08-11 22:25:17 -04:00
/** The read-only slot catalog, grouped by family, with slot metadata. */
2026-08-10 16:07:19 -04:00
slots : publicProcedure.query ( async () => {
return {
slots : CONFLUENCE_SLOTS.map (( s ) => ({
id : s.id ,
name : s.name ,
family : s.family ,
body : s.body ,
granularity : s.granularity ,
explain : s.explain ,
})),
slotIds : CONFLUENCE_SLOT_IDS ,
families : [ 'technical' , 'institutional' , 'macro' , 'seasonal' , 'flows' , 'sentiment' ] as SlotFamily [],
};
}),
/** List rack definitions (system + the calling user's own). */
racks : publicProcedure.query (({ ctx }) => {
const repo = new ConfluenceRepository ( ctx . db );
const system = repo . listSystemRacks ();
const user = ctx . userId ? repo . listUserRacks ( ctx . userId ) : [];
return { system , user };
}),
/** Latest evaluation for a symbol+rack, with the picture + change hint. */
evaluation : publicProcedure
. input ( z . object ({
symbol : z . string (). min ( 1 ). max ( 12 ),
rackId : z.string (). min ( 1 ). optional (),
limit : z.number (). int (). min ( 1 ). max ( 60 ). optional (),
}))
. query (({ ctx , input }) => {
const repo = new ConfluenceRepository ( ctx . db );
const symbol = input . symbol . toUpperCase ();
const evals = repo . listEvaluationsForSymbol ( symbol );
const rackId = input . rackId ?? ( repo . listSystemRacks ()[ 0 ] ? . id ?? null );
const forRack = rackId ? evals . filter (( e ) => e . rackId === rackId ) : evals ;
const latest = forRack . length > 0 ? forRack [ 0 ] : null ;
let change = null ;
if ( forRack . length >= 2 ) {
const prev = forRack [ 1 ];
change = detectPictureChange (
{ symbol , asOf : prev.asOf , assessments : prev.assessments , bullEvidence : prev.bullEvidence , bearEvidence : prev.bearEvidence , bullCount : prev.bullCount , bearCount : prev.bearCount , assessedCount : prev.assessedCount , netEvidence : prev.netEvidence , totalEvidence : prev.totalEvidence , quality : prev.quality },
{ symbol , asOf : latest ! . asOf , assessments : latest ! . assessments , bullEvidence : latest ! . bullEvidence , bearEvidence : latest ! . bearEvidence , bullCount : latest ! . bullCount , bearCount : latest ! . bearCount , assessedCount : latest ! . assessedCount , netEvidence : latest ! . netEvidence , totalEvidence : latest ! . totalEvidence , quality : latest ! . quality },
);
}
return {
rackId ,
symbol ,
latest ,
history : forRack.slice ( 0 , input . limit ?? 10 ),
change ,
};
}),
/** Run the slot backtest for a symbol from cached daily candles. */
backtest : publicProcedure
. input ( z . object ({
symbol : z . string (). min ( 1 ). max ( 12 ),
weeks : z.number (). int (). min ( 2 ). max ( 12 ). optional (),
}))
. query ( async ({ ctx , input }) => {
const repo = new ConfluenceRepository ( ctx . db );
const symbol = input . symbol . toUpperCase ();
const entry = await ctx . cache . get < PriceCandle [] >( `yfinance:candles: ${ symbol } :1d` );
const candles = ( entry ? . value ?? []) as PriceCandle [];
// Historic fire events come from signal history rows (as-of was the fire date).
const signals = repo . listSignalsForSymbol ( symbol );
const fires : ConfluenceFireEvent [] = signals . map (( s ) => ({ symbol , asOf : s.firedAt , slotId : s.slotId }));
return runSlotBacktest ( symbol , candles , fires , input . weeks ?? 4 );
}),
/** Per-slot reliability scorecard from resolved signal history. */
scorecard : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ). max ( 12 ). optional () }))
. query (({ ctx , input }) => {
const symbol = input ? . symbol ? . toUpperCase ();
const stats = signalHistoryToStats ( ctx . db , symbol );
return {
slots : stats ,
// Row semantics: clear, evidence-based.
note : 'Follow-through is measured over the standard 4-week horizon. Reliability is context, not a promise.' ,
};
}),
/** Create or update a user-owned rack. */
saveRack : protectedProcedure
. input ( z . object ({
id : z.string (). min ( 1 ). max ( 80 ). optional (),
name : z.string (). min ( 1 ). max ( 80 ),
description : z.string (). max ( 300 ). optional (),
2026-08-11 22:25:17 -04:00
slotIds : z.array ( z . string ()). min ( 1 ). max ( CONFLUENCE_SLOT_IDS . length ),
2026-08-10 16:07:19 -04:00
}))
. mutation (({ ctx , input }) => {
const repo = new ConfluenceRepository ( ctx . db );
const userId = ctx . userId ! ;
// Validate slot ids against the catalog.
for ( const id of input . slotIds ) {
if ( ! CONFLUENCE_SLOT_IDS . includes ( id )) throw new TRPCError ({ code : 'BAD_REQUEST' , message : `Unknown slot id: ${ id } ` });
}
let rack = repo . getRack ( input . id ?? '' );
if ( rack && rack . ownerId !== userId ) throw new TRPCError ({ code : 'FORBIDDEN' , message : 'Not your rack.' });
if ( rack ) {
repo . updateRackSlots ( rack . id , input . slotIds , input . name , input . description ?? null );
rack = repo . getRack ( rack . id ) ! ;
} else {
rack = rackFromSlots ( input . id ?? ` ${ userId } - ${ Date . now () } ` , input . name , input . slotIds , {
ownerId : userId , description : input.description ?? null ,
});
repo . saveRack ( rack );
}
return rack ;
}),
2026-08-11 22:25:17 -04:00
// -------------------------------------------------------------------------
// Price Corridor (M24): valuation-corridor snapshots + backtest ledger + link
// -------------------------------------------------------------------------
/** Latest valuation-corridor snapshot for a symbol, with SPY market context. */
corridorSnapshot : publicProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ). max ( 12 ) }))
. query (({ ctx , input }) => {
const repo = new CorridorRepository ( ctx . db );
const symbol = input . symbol . toUpperCase ();
const snapshot = repo . latestSnapshot ( symbol );
const spy = repo . latestSnapshot ( 'SPY' );
const series = repo . snapshotsForSymbol ( symbol , 90 );
const corridor = ( snapshot ? . corridor1yLow ?? null ) !== null
? {
low : snapshot ! . corridor1yLow ,
high : snapshot ! . corridor1yHigh ,
median : snapshot ! . corridor1yMedian ,
currentPE : snapshot ! . trailingPE ?? snapshot ! . forwardPE ,
fairValue1y : snapshot ! . fairValue1y ,
impliedUpside1y : snapshot ! . impliedUpside1y ,
pePercentile1y : snapshot ! . pePercentile1y ,
}
: null ;
return {
symbol ,
snapshot ,
spy ,
series ,
corridor ,
};
}),
/** Estuary user's corridor watchlist (tickers surfaced in the Corridor panel). */
corridorWatchlist : protectedProcedure
. query (({ ctx }) => {
const repo = new CorridorRepository ( ctx . db );
return { symbols : repo.listWatchlist ( ctx . userId ! ) };
}),
/** Add a ticker to the corridor watchlist. */
corridorWatchlistAdd : protectedProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ). max ( 12 ). transform (( s ) => s . toUpperCase ()) }))
. mutation (({ ctx , input }) => {
const repo = new CorridorRepository ( ctx . db );
repo . addToWatchlist ( ctx . userId ! , input . symbol );
return { symbols : repo.listWatchlist ( ctx . userId ! ) };
}),
/** Remove a ticker from the corridor watchlist. */
corridorWatchlistRemove : protectedProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ). max ( 12 ). transform (( s ) => s . toUpperCase ()) }))
. mutation (({ ctx , input }) => {
const repo = new CorridorRepository ( ctx . db );
repo . removeFromWatchlist ( ctx . userId ! , input . symbol );
return { symbols : repo.listWatchlist ( ctx . userId ! ) };
}),
/** Corridor-method backtest scorecard: @alojoh's rated names vs actual forward returns. */
corridorBacktest : publicProcedure
. query (({ ctx }) => {
const repo = new CorridorRepository ( ctx . db );
const rows = repo . listBacktests ();
return {
rows ,
aggregate : aggregateBacktest (
rows . map (( r ) => ({
articleDate : r.articleDate ,
articleId : r.articleId ,
rankingType : r.rankingType as 'entry_1y' | 'entry_90d' ,
topSymbols : r.topSymbols ,
bottomSymbols : r.bottomSymbols ,
topAvgReturn : r.topAvgReturn ,
bottomAvgReturn : r.bottomAvgReturn ,
spread : r.spread ,
isWin : r.isWin ,
horizonDays : r.horizonDays ,
resolvableCount : 0 ,
})),
),
};
}),
/** Replay the corridor-method backtest corpus against cached candles (offline; idempotent). */
corridorBacktestRun : publicProcedure
. mutation ( async ({ ctx }) => {
const { runCorridorBacktest } = await import ( '../analysis/corridorBacktest.ts' );
const result = await runCorridorBacktest ( ctx . db , async ( symbol ) => {
const entry = await ctx . cache . get < PriceCandle [] >( `yfinance:candles: ${ symbol . toUpperCase () } :1d` );
return ( entry ? . value ?? []) as PriceCandle [];
});
return { grades : result.grades.length , resolved : result.aggregate.resolved , aggregate : result.aggregate };
}),
/** Force a confluence evaluation cycle for the current symbol now (debug/admin). */
runEvaluationNow : protectedProcedure
. input ( z . object ({ symbol : z . string (). min ( 1 ). max ( 12 ). optional () }))
. mutation ( async ({ ctx , input }) => {
const summary = await runConfluenceEvaluationCycle ( ctx . db , ctx . cache , {
symbols : input?.symbol ? [ input . symbol ] : undefined ,
});
return summary ;
}),
2026-08-10 16:07:19 -04:00
});
2026-07-05 08:50:12 -04:00
export const appRouter = router ({
auth : authRouter , onboarding : onboardingRouter , market : marketRouter , dashboard : dashboardRouter ,
admin : adminRouter , alerts : alertsRouter , edgar : edgarRouter , institutional : institutionalRouter ,
2026-08-10 13:36:26 -04:00
watchlists : watchlistRouter , portfolio : portfolioRouter , options : optionsRouter , dealerMap : dealerMapRouter , dealerStudy : dealerStudyRouter , mentorLedger : mentorLedgerRouter , userLlm : userLlmRouter , reports : reportsRouter ,
2026-07-05 08:50:12 -04:00
screener : screenerRouter , strategies : strategyRouter , backtest : backtestRouter ,
2026-08-10 13:36:26 -04:00
sectorCrosslink : sectorCrosslinkRouter ,
2026-07-05 08:50:12 -04:00
derisking : deriskingRouter , macro : macroRouter , thesisMonitor : thesisMonitorRouter ,
x : xRouter , reddit : redditRouter , emotionLogger : emotionLoggerRouter ,
2026-08-10 13:36:26 -04:00
sizing : sizingRouter , risk : riskRouter , trades : tradesRouter , theses : thesesRouter ,
funds : fundsRouter ,
mirror : mirrorRouter ,
symbols : symbolsRouter ,
2026-08-10 16:07:19 -04:00
confluence : confluenceRouter ,
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