- SecLintAdapter implements SourceFetch, runs via shared queue/drain loop - Two new SourceKinds: sec-lint-holders, sec-lint-insiders (weekly schedules) - No-op cache handlers so drain->cache.set doesn't throw on lint keys - tRPC admin.queueLint(symbol, kind) — run lint for one symbol, returns LintResult - tRPC admin.queueLintAll(kind) — backfill ALL watched symbols at once - tRPC admin.dataQualityList() — query data_quality rows (filterable by symbol/kind) - InstitutionalDashboard: 'Lint holders' button + status badge in detail panel header - Admin queue page: Data Quality section with per-row status badges, 'Lint all' buttons - DEFAULT_RATE_MS includes 167ms (~6 req/s) for lint kinds matching EDGAR limiter
331 lines
13 KiB
TypeScript
331 lines
13 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[];
|
|
total: number;
|
|
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, opts?: { limit?: number; offset?: number }) =>
|
|
trpcQuery<Form13fResult>("edgar.form13f_holdings", {
|
|
cik,
|
|
accession,
|
|
...(opts?.limit != null ? { limit: opts.limit } : {}),
|
|
...(opts?.offset != null ? { offset: opts.offset } : {}),
|
|
}),
|
|
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 } : {}) }),
|
|
queueLint: (symbol: string, kind: 'sec-lint-holders' | 'sec-lint-insiders') =>
|
|
trpcMutate<LintResult>("admin.queueLint", { symbol, kind }),
|
|
queueLintAll: (input: { kind: 'sec-lint-holders' | 'sec-lint-insiders' }) =>
|
|
trpcMutate<{ total: number; results: LintResult[] }>("admin.queueLintAll", input),
|
|
dataQualityList: (input?: { symbol?: string; kind?: 'institution_filings' | 'insider_transactions' }) =>
|
|
trpcQuery<DataQualityRow[]>("admin.dataQualityList", input ?? {}),
|
|
},
|
|
};
|
|
|
|
export interface LintResult {
|
|
symbol: string;
|
|
kind: string;
|
|
discoveredCount: number;
|
|
storedCount: number;
|
|
missingCount: number;
|
|
backfilled: number;
|
|
stale: number;
|
|
status: 'ok' | 'gaps_found' | 'stale' | 'error';
|
|
detail: Record<string, unknown>;
|
|
}
|
|
|
|
export interface DataQualityRow {
|
|
symbol: string;
|
|
kind: string;
|
|
last_checked_at: string;
|
|
stored_count: number;
|
|
discovered_count: number;
|
|
missing_count: number;
|
|
stale: number;
|
|
status: string;
|
|
detail: string;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|