Files
investor-flow/app/src/lib/trpc.ts
T
Investor Flow BuildandClaude ca385c1960 feat(queue): adapter queue overhaul — pause/resume, error capture, scheduling
- Pause/resume persisted in queue_state; drain loop checks isPaused() and skips
- Error capture: every failed/backoff attempt logged to queue_errors with the
  full stack trace; admin queue page expands a failed job to stream it
- Retry controls: retryJob(key), retrySource(kind), clearDone(olderThanMs)
- Per-source scheduling: queue_schedules table + 30s enqueueDueSchedules loop
  (seed defaults sec-fetch 24h, yfinance 5min); admin UI lists/adds/deletes
- Startup recovery: interrupted in_flight jobs reset to pending on boot
- fix(edgar): archive URLs use the filer CIK (accession-number prefix), not the
  company CIK — resolves Cloudflare 429 that left SEC backfills sparse/empty
- Migration: add queue_errors, queue_schedules, queue_state tables plus error/
  scheduled_for columns on adapter_queue (idempotent ALTER on startup)

Verified: full sec-fetch backfill now succeeds for all watched symbols
(NVDA 14,675 institution filings, CIFR 274 insider txns, TSLA 6,011, etc.).
503 backend tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-12 12:55:59 -04:00

295 lines
12 KiB
TypeScript

// 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<T>(res: Response): Promise<T> {
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<T>(procedure: string, input?: unknown): Promise<T> {
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<T>(res);
} finally {
clearTimeout(timer);
}
}
async function trpcMutate<T>(procedure: string, input: unknown): Promise<T> {
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<T>(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<number, Array<{ ts: string; value: number }>>;
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<Record<string, unknown>>;
}
export const api = {
market: {
snapshot: (symbol: string) => trpcQuery<Snapshot>("market.snapshot", { symbol }),
candles: (symbol: string, timeframe = "1d") =>
trpcQuery<PriceCandle[]>("market.candles", { symbol, timeframe }),
indicators: (symbol: string, opts: IndicatorOptions) =>
trpcQuery<IndicatorResult>("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<string, unknown>; 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<FilingsSearchResult>("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<AuthUser | null>("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<WatchlistEntry[]>("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<Record<string, unknown>> }>("options.chain", { symbol, ...opts }),
greeks: (symbol: string, opts?: { expiry?: string; strike?: number }) =>
trpcQuery<{ symbol: string; greeks: Record<string, unknown> | null; strike?: number; right?: string; lastPrice?: number; impliedVolatility?: number }>("options.greeks", { symbol, ...opts }),
},
portfolio: {
holdings: () => trpcQuery<PortfolioHolding[]>("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<Record<string, unknown>[]>("dashboard.rollup", {}),
},
institutional: {
flow: (symbol: string) =>
trpcQuery<{ symbol: string; flow: Array<Record<string, unknown>>; quarters: string[] }>("institutional.flow", { symbol }),
insiderStream: (symbol: string, limit = 200) =>
trpcQuery<{ symbol: string; events: Array<Record<string, unknown>>; netShares: number; count: number }>("institutional.insiderStream", { symbol, limit }),
},
emotionLogger: {
add: (input: { tradeExecutionId: string; priceAtEvent?: number; emotion: string; note?: string }) =>
trpcMutate<{ success: boolean; log: Record<string, unknown> }>("emotionLogger.add", input),
getByTrade: (tradeExecutionId: string) =>
trpcQuery<{ logs: Record<string, unknown>[] }>("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<Array<{ id: string; email: string; complexity: string; created_at: string; is_admin: number }>>("admin.usersList"),
userSessions: (userId: string) => trpcQuery<Array<{ id: string; user_id: string; expires_at: string; created_at: string }>>("admin.userSessions", { userId }),
resetPassword: (email: string, tempPassword: string) => trpcMutate<{ userId: string }>("admin.resetPassword", { email, tempPassword }),
queueHealth: () => trpcQuery<Array<{ source: string; status: string; count: number; last_attempt: string | null; retry_count: number; backoff_until: string | null }>>("admin.queueHealth"),
resetBackoff: (sourceKind: string) => trpcMutate<{ cleared: number }>("admin.resetBackoff", { sourceKind }),
queueSecFetch: (symbol: string) => trpcMutate<{ ok: boolean }>("admin.queueSecFetch", { symbol }),
queueStatus: () => trpcQuery<QueueStatus>("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<ScheduleEntry[]>("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<ErrorLogEntry[]>("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<Array<{ id: string; actor: string; action: string; target: string | null; ts: string; meta: string | null }>>("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<string, number>;
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;
}