// Thin typed client over the tRPC backend (DESIGN.md ยง2). Slice 1 uses a fetch wrapper // rather than @trpc/client to keep the Next app decoupled from the server's Node-native // type graph; the server is a real tRPC server and full end-to-end client types can be // layered later without changing this surface. export interface Quote { symbol: string; price: number; bid?: number | null; ask?: number | null; change?: number | null; changePercent?: number | null; iv?: number | null; } export interface PriceCandle { ts: string; o: number; h: number; l: number; c: number; v: number; adjClose?: number | null; } export interface SymbolMeta { symbol: string; name?: string | null; sector?: string | null; industry?: string | null; exchange?: string | null; tickerKind: string; peers?: string[] | null; } export interface Snapshot { symbol: string; quote: Quote | null; candles: PriceCandle[] | null; sector: SymbolMeta | null; stale: { quote: boolean; candles: boolean; sector: boolean }; } export interface AuthUser { userId: string; email?: string; complexity?: string; riskTolerance?: string; convexityPosture?: string; onboarded?: boolean; } // 8-second timeout for all tRPC calls to prevent hung requests. const TRPC_TIMEOUT_MS = 8_000; async function parse(res: Response): Promise { if (!res.ok) { const body = await res.text(); throw new Error(`tRPC ${res.status}: ${body}`); } const json = await res.json(); if (json?.error) { throw new Error(`tRPC error: ${json.error.message ?? json.error.data?.code ?? "unknown"}`); } return json.result.data as T; } async function trpcQuery(procedure: string, input?: unknown): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TRPC_TIMEOUT_MS); try { const search = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : ""; const res = await fetch(`/api/trpc/${procedure}${search}`, { headers: { "content-type": "application/json" }, credentials: "include", signal: controller.signal, }); return parse(res); } finally { clearTimeout(timer); } } async function trpcMutate(procedure: string, input: unknown): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TRPC_TIMEOUT_MS); try { const res = await fetch(`/api/trpc/${procedure}`, { method: "POST", headers: { "content-type": "application/json" }, credentials: "include", body: JSON.stringify(input), signal: controller.signal, }); return parse(res); } finally { clearTimeout(timer); } } export type Complexity = "beginner" | "intermediate" | "advanced"; export interface StarterEntry { symbol: string; tickerKind: string; reason: string; } export interface PortfolioEntry { symbol: string; qty: number; avgCost: number; acquiredAt: string; } export interface IndicatorOptions { ema?: number[]; rsi?: { period?: number }; relvol?: { period?: number }; } export interface IndicatorResult { ema?: Record>; rsi?: Array<{ ts: string; value: number }>; relvol?: Array<{ ts: string; value: number }>; } // --- EDGAR / SEC Filings types (Slice 6 / M6) ------------------------------- export interface EdgarFiling { form: string; dateReporter: string; accessionNumber: string; accessionNormalization: string; reportDate: string; reportFile: string; primaryDocument: string; } export interface FilerMeta { cik: string; name: string | null; sic: string | null; } export interface Form13fHolding { cusip: string; issuerName: string; value: number; sshPrnamt: number; } export interface Form13fResult { holdings: Form13fHolding[]; accession: string; } export interface Form4Transaction { reporter: string; relationship: string; securityTitle: string; transactionDate: string; transactionCode: string; shares: number; price: number; } export interface Form4Result { transactions: Form4Transaction[]; accession: string; } export interface FilingsSearchResult { filings: Array>; } export const api = { market: { snapshot: (symbol: string) => trpcQuery("market.snapshot", { symbol }), candles: (symbol: string, timeframe = "1d") => trpcQuery("market.candles", { symbol, timeframe }), indicators: (symbol: string, opts: IndicatorOptions) => trpcQuery("market.indicators", { symbol, ...opts }), }, edgar: { filings_index: (input: { cik: string; formTypes?: string[]; dateRange?: { from?: string; to?: string } }) => trpcQuery<{ filings: EdgarFiling[]; cik: string }>("edgar.filings_index", input), company_facts: (cik: string) => trpcQuery<{ facts: Record; cik: string }>("edgar.company_facts", { cik }), filer_cik_meta: (cik: string) => trpcQuery<{ meta: FilerMeta; cik: string }>("edgar.filer_cik_meta", { cik }), full_text_search: (q: string) => trpcQuery("edgar.full_text_search", { q }), form13f_holdings: (cik: string, accession: string) => trpcQuery<{ holdings: Form13fResult }>("edgar.form13f_holdings", { cik, accession }), form4_tx: (cik: string, accession: string) => trpcQuery<{ transactions: Form4Result }>("edgar.form4_tx", { cik, accession }), }, auth: { signup: (email: string, password: string) => trpcMutate<{ userId: string }>("auth.signup", { email, password }), login: (email: string, password: string, totp?: string) => trpcMutate<{ userId: string }>("auth.login", { email, password, ...(totp ? { totp } : {}) }), logout: () => trpcMutate<{ ok: boolean }>("auth.logout", {}), me: () => trpcQuery("auth.me"), enable2fa: () => trpcMutate<{ totpSecret: string; qrUrl: string; backupCodes: string[] }>("auth.enable2fa", {}), confirm2fa: (totp: string) => trpcMutate<{ ok: boolean }>("auth.confirm2fa", { totp }), oauthStart: (provider: "github" | "google", redirectUri: string) => trpcMutate<{ redirectUrl: string; state: string }>("auth.oauthStart", { provider, redirectUri }), }, onboarding: { starter: () => trpcQuery<{ watchlist: StarterEntry[]; disclaimer: string }>("onboarding.starter"), complete: (input: { complexity: Complexity; drawdownTolerancePct?: number; firstWatchlistSymbols?: string[]; portfolio?: PortfolioEntry[] }) => trpcMutate<{ ok: boolean; watchlistId: string }>("onboarding.complete", input), }, watchlists: { list: () => trpcQuery("watchlists.list"), addSymbol: (symbol: string) => trpcMutate<{ added: boolean }>("watchlists.addSymbol", { symbol }), removeSymbol: (symbol: string) => trpcMutate<{ removed: boolean }>("watchlists.removeSymbol", { symbol }), }, options: { chain: (symbol: string, opts?: { expiry?: string }) => trpcQuery<{ symbol: string; expiration: string | null; rows: Array> }>("options.chain", { symbol, ...opts }), greeks: (symbol: string, opts?: { expiry?: string; strike?: number }) => trpcQuery<{ symbol: string; greeks: Record | null; strike?: number; right?: string; lastPrice?: number; impliedVolatility?: number }>("options.greeks", { symbol, ...opts }), }, portfolio: { holdings: () => trpcQuery("portfolio.holdings"), addHolding: (symbol: string, shares: number, avgCost: number) => trpcMutate<{ created: boolean }>("portfolio.addHolding", { symbol, shares, avgCost }), removeHolding: (symbol: string) => trpcMutate<{ removed: boolean }>("portfolio.removeHolding", { symbol }), }, dashboard: { rollup: () => trpcQuery[]>("dashboard.rollup", {}), }, institutional: { flow: (symbol: string) => trpcQuery<{ symbol: string; flow: Array>; quarters: string[] }>("institutional.flow", { symbol }), insiderStream: (symbol: string, limit = 200) => trpcQuery<{ symbol: string; events: Array>; netShares: number; count: number }>("institutional.insiderStream", { symbol, limit }), }, emotionLogger: { add: (input: { tradeExecutionId: string; priceAtEvent?: number; emotion: string; note?: string }) => trpcMutate<{ success: boolean; log: Record }>("emotionLogger.add", input), getByTrade: (tradeExecutionId: string) => trpcQuery<{ logs: Record[] }>("emotionLogger.getByTrade", { tradeExecutionId }), delete: (id: string) => trpcMutate<{ success: boolean }>("emotionLogger.delete", { id }), }, alerts: { unackedCount: () => trpcQuery<{ count: number }>("alerts.unackedCount"), acknowledgeAll: () => trpcMutate<{ ok: boolean }>("alerts.acknowledgeAll"), }, admin: { usersList: () => trpcQuery>("admin.usersList"), userSessions: (userId: string) => trpcQuery>("admin.userSessions", { userId }), resetPassword: (email: string, tempPassword: string) => trpcMutate<{ userId: string }>("admin.resetPassword", { email, tempPassword }), queueHealth: () => trpcQuery>("admin.queueHealth"), resetBackoff: (sourceKind: string) => trpcMutate<{ cleared: number }>("admin.resetBackoff", { sourceKind }), queueSecFetch: (symbol: string) => trpcMutate<{ ok: boolean }>("admin.queueSecFetch", { symbol }), queueStatus: () => trpcQuery("admin.queueStatus"), queuePause: () => trpcMutate<{ paused: boolean }>("admin.queuePause", undefined), queueResume: () => trpcMutate<{ paused: boolean }>("admin.queueResume", undefined), queueClearDone: (olderThanHours: number) => trpcMutate<{ cleared: number }>("admin.queueClearDone", { olderThanHours }), queueSchedules: () => trpcQuery("admin.queueSchedules"), queueSetSchedule: (sourceKind: string, intervalMs: number) => trpcMutate<{ ok: boolean }>("admin.queueSetSchedule", { sourceKind, intervalMs }), queueDeleteSchedule: (sourceKind: string) => trpcMutate<{ ok: boolean }>("admin.queueDeleteSchedule", { sourceKind }), queueLogs: (key: string, limit: number) => trpcQuery("admin.queueLogs", { key, limit }), queueRetryJob: (key: string) => trpcMutate<{ ok: boolean }>("admin.queueRetryJob", { key }), queueRetrySource: (sourceKind: string) => trpcMutate<{ cleared: number }>("admin.queueRetrySource", { sourceKind }), auditLog: (limit = 50, offset = 0, actor?: string, action?: string) => trpcQuery>("admin.auditLog", { limit, offset, ...(actor ? { actor } : {}), ...(action ? { action } : {}) }), }, }; export interface WatchlistEntry { symbol: string; added_at: string; notes?: string | null; } export interface PortfolioHolding { symbol: string; shares: number; avg_cost: number; added_at: string; } export interface QueueStatus { paused: boolean; queued: number; in_flight: number; failed: number; backoff: number; done: number; backoff_until: string | null; last_error: string | undefined; counts: Record; lastErrors: Array<{ key: string; error: string | null }>; } export interface ScheduleEntry { sourceKind: string; intervalMs: number; lastEnqueued: string | null; nextEnqueue: string | null; } export interface ErrorLogEntry { attempt: number; errorMessage: string | null; errorStack: string | null; ts: string; }