fix: backfill symbol_demand for sidebar-added symbols + analyst ratings schema fix

- Add await ctx.cache.subscribe() to addSymbol mutation so symbols
  added via the sidebar get registered in symbol_demand and yfinance
  jobs are queued immediately
- Backfill PEP, WYNN, STZ, CELH into symbol_demand + adapter_queue
- Upgrade yahoo-finance2 3.15.3 -> 3.15.4 and pass validateResult:false
  to quoteSummary() to handle Yahoo schema drift
- Add error detail logging for analyst ratings schema failures
- Update .gitignore with common ignores
This commit is contained in:
Investor Flow Build
2026-07-23 18:02:24 -04:00
parent 5ef2b2f060
commit e262187c3c
204 changed files with 25014 additions and 2934 deletions
+28
View File
@@ -0,0 +1,28 @@
/**
* Chart theme — token-driven colors for all data viz (recharts + custom SVG).
* Values reference CSS variables so charts re-theme with the [data-theme]
* presets in globals.css. EMA hues are fixed, distinct colors (work on any
* background); grid/axis/up/down/accent follow the active theme.
*/
export const chart = {
grid: "var(--color-line)",
axis: "var(--color-fg-muted)",
text: "var(--color-fg-muted)",
up: "var(--color-up)",
down: "var(--color-down)",
accent: "var(--color-accent)",
neutral: "#6b7280",
emaColors: {
9: "#f472b6",
21: "#a78bfa",
50: "#60a5fa",
200: "#34d399",
},
tooltip: {
backgroundColor: "var(--color-surface-raised)",
border: "1px solid var(--color-line)",
borderRadius: 6,
color: "var(--color-fg)",
fontSize: 12,
},
} as const;
+124
View File
@@ -0,0 +1,124 @@
// Options Pure — pure math utilities for the options DD panel.
// No I/O, no React. Fully testable.
import type { OptionChainRow } from "@/types/options";
export interface IVRankResult {
currentIV: number;
rank: number;
percentile: number;
iv52wLow: number;
iv52wHigh: number;
}
/** Compute IV rank and percentile from low/high range. */
export function computeIVRank(currentIV: number, high: number, low: number): IVRankResult {
const range = high - low;
if (range === 0) return { currentIV, rank: 50, percentile: 50, iv52wLow: low, iv52wHigh: high };
const raw = (currentIV - low) / range;
const clamped = Math.max(0, Math.min(1, raw));
return {
currentIV,
rank: Math.round(clamped * 100),
percentile: Math.round(clamped * 100),
iv52wLow: low,
iv52wHigh: high,
};
}
/** Classify an option position as defined-risk (spread) or undefined (naked). */
export function classifyRisk(right: 'call' | 'put', isSpread: boolean): 'defined' | 'undefined' {
return isSpread ? 'defined' : 'undefined';
}
/** Compute PnL for a single option at a given stock price. */
export function computeOptionPayoff(
stockPrice: number,
strike: number,
right: 'call' | 'put',
premium: number,
): number {
const intrinsic = right === 'call'
? Math.max(0, stockPrice - strike)
: Math.max(0, strike - stockPrice);
return intrinsic - premium;
}
export interface PayoffPoint {
stockPrice: number;
pnl: number;
}
/** Generate a payoff curve over a range of stock prices. */
export function generatePayoffCurve(
strikes: number[],
stockPrice: number,
right: 'call' | 'put',
premium: number,
numPoints: number,
): PayoffPoint[] {
const minPrice = stockPrice * 0.5;
const maxPrice = stockPrice * 1.5;
const step = (maxPrice - minPrice) / (numPoints - 1);
const points: PayoffPoint[] = [];
for (let i = 0; i < numPoints; i++) {
const sp = minPrice + step * i;
points.push({
stockPrice: Math.round(sp * 100) / 100,
pnl: computeOptionPayoff(sp, strikes[0], right, premium),
});
}
// Include breakeven point (where PnL = 0) if within range
const breakeven = strikes[0] + (right === 'call' ? premium : -premium);
if (breakeven >= minPrice && breakeven <= maxPrice) {
points.push({
stockPrice: Math.round(breakeven * 100) / 100,
pnl: 0,
});
}
points.sort((a, b) => a.stockPrice - b.stockPrice);
return points;
}
export interface OIWall {
strike: number;
openInterest: number;
right: 'call' | 'put';
}
/** Extract open interest walls from option chain rows. Sorted by strike. */
export function extractOIWalls(
rows: OptionChainRow[],
): OIWall[] {
return rows
.filter((r) => (r.openInterest ?? 0) > 0)
.map((r) => ({ strike: r.strike, openInterest: r.openInterest ?? 0, right: r.right as 'call' | 'put' }))
.sort((a, b) => a.strike - b.strike);
}
/** Compute max pain strike — the strike where total option value is minimized. */
export function computeMaxPain(
rows: OptionChainRow[],
): number {
if (rows.length === 0) return 0;
const strikes = [...new Set(rows.map((r) => r.strike))].sort((a, b) => a - b);
let minPain = Infinity;
let maxPainStrike = strikes[0];
for (const sp of strikes) {
let total = 0;
for (const r of rows) {
const intrinsic = r.right === 'call'
? Math.max(0, sp - r.strike)
: Math.max(0, r.strike - sp);
total += intrinsic * (r.openInterest ?? 0);
}
if (total < minPain) {
minPain = total;
maxPainStrike = sp;
}
}
return maxPainStrike;
}
+32 -2
View File
@@ -43,10 +43,30 @@ export const UI_STRINGS = {
completeOnboardingButton: "Finish setup",
onboardingDone: "Setup complete — your starter watchlist is tracking.",
// Portfolio & Watchlist panels
portfolioTitle: "Your holdings",
portfolioCaption:
"Record positions you already hold or want to model: symbol, shares, and average cost. Investor Flow does not connect to a broker. Exposure uses cost basis (shares × avg cost), not live marks.",
emptyPortfolio:
"No open holdings yet. Enter symbol, shares, and average cost below. The sidebar watchlist is only a research list - it does not feed risk posture.",
portfolioVsWatchlist:
"Watchlist = tickers to research. Holdings = your position book (shares + cost) that risk posture reads.",
manageHoldingsLink: "Manage holdings on Risk",
optionLegsTitle: "Option legs",
optionLegsCaption:
"Record calls and puts you already hold: long debits, covered calls, and cash-secured puts. Premium is per share; capital math uses cost basis / cash reserve - not live marks or greeks. Full options sleeve workflow comes later.",
emptyOptionLegs:
"No open option legs yet. Choose a role, then enter underlying, strike, expiry, contracts, and premium.",
optionPremiumAtRiskLabel: "Premium at risk",
optionCashReservedLabel: "Cash reserved (CSPs)",
optionCreditLabel: "Credits received",
watchlistTitle: "Watchlist",
emptyWatchlist: "Your watchlist is empty.",
// Chart Lab (Slice 5c)
chartLabTitle: "Chart Lab — price, volume, and indicator overlays",
chartsTitle: "Charts — price, volume, and indicator overlays",
priceChartCaption:
"Daily price range (bar) with closing-price line — visual context, not a signal.",
"Closing-price line with EMA overlays — visual context, not a signal.",
volumeLabel: "Volume",
volumeCaption:
"Number of shares traded per day — relative size, not a directional cue.",
@@ -67,6 +87,16 @@ export const UI_STRINGS = {
relvolTooltip:
"Relative volume shows whether trading activity is above or below the recent average; it does not predict direction.",
noCandleData: "No candle data available for this symbol/timeframe.",
// M20 Risk Posture
riskPostureTitle: "Risk posture",
riskPostureSubtitle:
"Your complete risk picture: holdings exposure, drawdown vs tolerance, clusters, asymmetry, and the gentle-halt circuit breaker - plus the sizing math cascade.",
riskAccountInputsCaption:
"Equity and peak equity are for drawdown teaching - enter total account value. Holdings below are the position book for cluster and exposure math. Cash or other assets can make equity differ from holdings total; that is fine for this lesson. Investor Flow does not connect to a broker.",
sizingTitle: "Position sizing math",
sizingCaption:
"Hypothetical calculator - not how you enter the live book. Layers cascade from risk budget → stop width → conviction tier → macro gate. The result is math, not an order.",
} as const;
export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS);
+484 -12
View File
@@ -2,6 +2,9 @@
// 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.
import type { OptionChainRow, OptionGreeks } from "@/types/options";
export interface Quote {
symbol: string;
price: number;
@@ -27,8 +30,295 @@ export interface SymbolMeta {
industry?: string | null;
exchange?: string | null;
tickerKind: string;
description?: string | null;
website?: string | null;
marketCap?: number | null;
currency?: string | null;
employees?: number | null;
country?: string | null;
city?: string | null;
peers?: string[] | null;
}
export interface XPost {
post_id: string;
author_handle: string;
body_text: string | null;
posted_at: string;
engagement: number;
cashtag?: string | null;
attribution?: string | null;
}
export interface XAccountRow {
id: string;
symbol: string;
handle: string;
label: string;
created_at: string;
}
export interface XCredentialsStatus {
configured: boolean;
healthy: 'healthy' | 'degraded';
lastError: string | null;
updatedAt: string | null;
}
export interface SectorRotationItem {
symbol: string;
name: string;
/** Legacy: inflow ≈ leading, outflow ≈ lagging (RS vs SPY, not abs flow). */
group: 'outflow' | 'inflow';
subGroup: string;
kind?: 'sector' | 'style' | 'region' | 'thematic';
leadership?: 'leading' | 'lagging' | 'inline' | 'unknown';
earlyWatch?: boolean;
rank1M?: number | null;
rank1W?: number | null;
relVol?: number | null;
oneWeek: number | null;
oneMonth: number | null;
threeMonth: number | null;
sixMonth: number | null;
oneYear: number | null;
rsOneWeek?: number | null;
rsOneMonth?: number | null;
rsThreeMonth?: number | null;
rsSixMonth?: number | null;
rsOneYear?: number | null;
}
export interface AlertEventRow {
id: string;
userId: string;
type: string;
severity: string;
title: string;
description: string;
symbol?: string;
createdAt: string;
acknowledged: boolean;
dedupKey: string;
payload: Record<string, unknown>;
}
export interface RotationData {
sectors: SectorRotationItem[];
signal: {
strength: 'none' | 'weak' | 'moderate' | 'strong';
spread: number;
inflowAvg: number;
outflowAvg: number;
leadingSubGroup: string;
laggingSubGroup?: string;
inflowConfirmation: string;
outflowConfirmation: string;
earlyWatchCount?: number;
daysSince: number | null;
summary?: string;
};
benchmark?: string;
coveredSymbols?: number;
totalSymbols?: number;
earlyHistory?: Array<{ date: string; symbol: string; name: string; rs1w: number | null }>;
lesson?: string;
fetchedAt: string;
}
export interface MarketCondition {
regime: 'trending-up' | 'trending-down' | 'range-bound';
regimePlain?: string;
confidence: number;
factors: string[];
explanation: string;
metrics: {
spy1M: number | null;
vix: number | null;
treasury10Y: number | null;
treasury2Y: number | null;
curve10y2y: number | null;
unemployment: number | null;
};
risks: Array<{
id: string;
label: string;
severity: 'info' | 'warning' | 'elevated';
detail: string;
}>;
updatedAt: string;
lesson: string;
}
export interface SeasonalityMonth {
month: number;
monthName: string;
avgReturnPct: number;
winRate: number;
sampleYears: number;
}
export interface HorizonReturns {
oneWeek: number | null;
oneMonth: number | null;
threeMonth: number | null;
sixMonth: number | null;
oneYear: number | null;
threeYear: number | null;
fiveYear: number | null;
}
export interface TickerContext {
symbol: string;
name: string | null;
sector: string | null;
industry: string | null;
tickerKind: string;
performance: {
symbol: HorizonReturns;
vsMarket: HorizonReturns;
vsSector: HorizonReturns | null;
vsPeers1M: number | null;
marketStance1M: "outperforming" | "underperforming" | "inline" | "unknown";
sectorStance1M: "outperforming" | "underperforming" | "inline" | "unknown";
peerStance1M: "outperforming" | "underperforming" | "inline" | "unknown";
};
market: {
regime: string | null;
confidence: number | null;
spyReturns: HorizonReturns;
};
sectorContext: {
label: string | null;
etf: string | null;
etfReturns: HorizonReturns | null;
leadership: string | null;
};
themeContext: {
label: string | null;
etf: string | null;
etfReturns: HorizonReturns | null;
};
classification?: {
note: string | null;
vendorSector: string | null;
vendorIndustry: string | null;
};
peers: Array<{
symbol: string;
name: string | null;
oneMonth: number | null;
rsVsMarket1M: number | null;
}>;
summary: {
headline: string;
market: string;
sector: string;
peers: string;
synthesis: string;
};
}
export interface SeasonalityData {
symbol: string;
months: SeasonalityMonth[];
currentMonth: number;
currentMonthAvgPct: number | null;
currentMonthWinRate: number | null;
currentMonthSampleYears: number;
halfYear: {
winterAvgPct: number | null;
summerAvgPct: number | null;
whichHalf: 'winter' | 'summer';
};
electionCycle: {
year: number;
yearInCycle: 1 | 2 | 3 | 4;
label: string;
};
calendar: {
dayOfMonth: number;
nearTurnOfMonth: boolean;
quarter: 1 | 2 | 3 | 4;
nearQuarterEnd: boolean;
};
events: Array<{
id: string;
title: string;
when: string;
impact: 'high' | 'medium';
plainWhy: string;
}>;
hasEnoughHistory: boolean;
lesson: string;
}
export interface RotationAlertCheckResult {
signal: string;
spread: number;
inflowAvg: number;
outflowAvg: number;
prevSignal: string | null;
alertsCreated: number;
}
export interface SectorHolding {
symbol: string;
name: string;
weight: number;
price: number | null;
change: number | null;
changePercent: number | null;
volume: number | null;
marketCap: number | null;
returns1W: number | null;
returns1M: number | null;
returns3M: number | null;
returns6M: number | null;
returns1Y: number | null;
}
export interface SectorHoldingsResult {
symbol: string;
holdings: SectorHolding[];
/** composition source: live Yahoo, 24h cache, static fallback, or empty */
source?: "live" | "cache" | "static" | "empty";
}
export interface TruckSalesObservation {
date: string;
value: number;
}
export interface TruckSalesData {
observations: TruckSalesObservation[];
seriesTitle: string;
units: string;
configured: boolean;
error?: string;
}
export interface ManufacturingPmiData {
observations: TruckSalesObservation[];
seriesTitle: string;
units: string;
configured: boolean;
error?: string;
}
export interface XFedFeed {
cashtagPosts: XPost[];
accountPosts: XPost[];
nextCursor: string | null;
configured: boolean;
health?: { healthy: 'healthy' | 'degraded'; lastError: string | null } | null;
}
export interface MacroCommentary {
shortTerm: string;
longTerm: string;
disclaimer: string;
}
export interface Snapshot {
symbol: string;
quote: Quote | null;
@@ -97,15 +387,20 @@ 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 IndicatorPeriods {
ema: number[];
rsi: number;
relvol: number;
}
export interface IndicatorOptions {
ema?: number[];
rsi?: { period?: number };
relvol?: { period?: number };
periods?: IndicatorPeriods;
timeframe?: '1d' | '1wk' | '1mo';
}
export interface IndicatorResult {
ema?: Record<number, Array<{ ts: string; value: number }>>;
rsi?: Array<{ ts: string; value: number }>;
relvol?: Array<{ ts: string; value: number }>;
ema?: Record<number, (number | null)[]>;
rsi?: (number | null)[];
relvol?: (number | null)[];
macd?: { macdLine: (number | null)[]; signalLine: (number | null)[]; histogram: (number | null)[] };
}
// --- EDGAR / SEC Filings types (Slice 6 / M6) -------------------------------
@@ -161,10 +456,19 @@ export interface FilingsSearchResult {
export const api = {
market: {
snapshot: (symbol: string) => trpcQuery<Snapshot>("market.snapshot", { symbol }),
candles: (symbol: string, timeframe = "1d") =>
trpcQuery<PriceCandle[]>("market.candles", { symbol, timeframe }),
tickerContext: (symbol: string) => trpcQuery<TickerContext>("market.tickerContext", { symbol }),
candles: async (symbol: string, timeframe = "1d") => trpcQuery<{ symbol: string; timeframe: string; candles: PriceCandle[]; isStale: boolean }>("market.candles", { symbol, timeframe }).then((r) => r?.candles ?? []),
indicators: (symbol: string, opts: IndicatorOptions) =>
trpcQuery<IndicatorResult>("market.indicators", { symbol, ...opts }),
truckSales: () => trpcQuery<TruckSalesData>("market.truckSales"),
manufacturingPmi: () => trpcQuery<ManufacturingPmiData>("market.manufacturingPmi"),
condition: () => trpcQuery<MarketCondition>("market.condition"),
rotation: () => trpcQuery<RotationData>("market.rotation"),
rotationCheckForAlert: () => trpcMutate<RotationAlertCheckResult>("market.rotationCheckForAlert", {}),
seasonality: (symbol?: string) =>
trpcQuery<SeasonalityData>("market.seasonality", symbol ? { symbol } : {}),
sectorHoldings: (etfSymbol: string) => trpcQuery<SectorHoldingsResult>("market.sectorHoldings", { etfSymbol }),
commentary: () => trpcQuery<MacroCommentary>("macro.commentary"),
},
edgar: {
filings_index: (input: { cik: string; formTypes?: string[]; dateRange?: { from?: string; to?: string } }) =>
@@ -206,14 +510,48 @@ export const api = {
},
options: {
chain: (symbol: string, opts?: { expiry?: string }) =>
trpcQuery<{ symbol: string; expiration: string | null; rows: Array<Record<string, unknown>> }>("options.chain", { symbol, ...opts }),
trpcQuery<{ symbol: string; expiration: string | null; rows: OptionChainRow[] }>("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 }),
trpcQuery<{ symbol: string; greeks: OptionGreeks | null; strike?: number; right?: "call" | "put"; 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 }),
optionLegs: () => trpcQuery<PortfolioOptionLeg[]>("portfolio.optionLegs"),
addOptionLeg: (input: AddOptionLegInput) =>
trpcMutate<{ id: string; legs: PortfolioOptionLeg[] }>("portfolio.addOptionLeg", input),
removeOptionLeg: (id: string) => trpcMutate<{ removed: boolean }>("portfolio.removeOptionLeg", { id }),
},
sizing: {
compute: (input: {
symbol: string;
tier: "A_STAR" | "A" | "B" | "C";
riskFraction: number;
stopPerShare: number;
equity: number;
complexity?: "beginner" | "intermediate" | "advanced";
regime?: "trending-up" | "trending-down" | "range-bound";
aStarUnlocked?: boolean;
macroOverrideReason?: string;
}) =>
trpcQuery<SizingResult>("sizing.compute", input),
},
risk: {
posture: (input: {
equity: number;
peakEquity?: number;
drawdownTolerancePct?: number;
regime?: "trending-up" | "trending-down" | "range-bound";
plans?: Array<{
symbol: string;
stopPrice?: number;
rewardTarget?: number;
cluster?: string;
}>;
}) => trpcQuery<RiskPostureResult>("risk.posture", input),
haltStatus: () =>
trpcQuery<{ active: boolean; haltedUntil: string | null; triggeredBy: string | null }>("risk.haltStatus"),
},
dashboard: {
rollup: () => trpcQuery<Record<string, unknown>[]>("dashboard.rollup", {}),
@@ -223,6 +561,12 @@ export const api = {
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 }),
ownershipHistory: (symbol: string) =>
trpcQuery<{ symbol: string; months: Array<{ month: string; total_shares: number; num_filers: number }> }>("institutional.ownershipHistory", { symbol }),
buyEvents: (symbol: string) =>
trpcQuery<{ symbol: string; events: Array<{ filerName: string | null; sharesAdded: number; estimatedPrice: number; date: string }> }>("institutional.buyEvents", { symbol }),
analystRatings: (symbol: string) =>
trpcQuery<{ symbol: string; ratings: Array<{ firm: string; action: string | null; gradeFrom: string | null; gradeTo: string | null; ratingDate: string; targetFrom: number | null; targetTo: number | null }>; consensus: { strongBuy: number; buy: number; hold: number; sell: number; strongSell: number } | null; error?: string }>("institutional.analystRatings", { symbol }),
},
emotionLogger: {
add: (input: { tradeExecutionId: string; priceAtEvent?: number; emotion: string; note?: string }) =>
@@ -233,11 +577,18 @@ export const api = {
trpcMutate<{ success: boolean }>("emotionLogger.delete", { id }),
},
alerts: {
list: (limit = 50) => trpcQuery<AlertEventRow[]>("alerts.list", { limit }),
acknowledge: (alertId: string) => trpcMutate<{ ok: boolean }>("alerts.acknowledge", { alertId }),
unackedCount: () => trpcQuery<{ count: number }>("alerts.unackedCount"),
acknowledgeAll: () => trpcMutate<{ ok: boolean }>("alerts.acknowledgeAll"),
acknowledgeAll: () => trpcMutate<{ ok: boolean }>("alerts.acknowledgeAll", {}),
},
x: {
cashtag_search: (symbol: string, limit = 30) => trpcQuery<{ posts: XPost[]; configured: boolean }>("x.cashtag_search", { symbol, limit }),
feed: (symbol: string, cursor?: string) => trpcQuery<XFedFeed>("x.feed", { symbol, limit: 10, cursor }),
accountsForSymbol: (symbol: string) => trpcQuery<Array<{id:string; handle:string; label:string}>>("x.accountsForSymbol", { symbol }),
},
admin: {
usersList: () => trpcQuery<Array<{ id: string; email: string; complexity: string; created_at: string; is_admin: number }>>("admin.usersList"),
usersList: () => trpcQuery<Array<{ id: string; email: string; complexity: string; created_at: string; is_admin: number; status: string }>>("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"),
@@ -259,8 +610,21 @@ export const api = {
trpcMutate<LintResult>("admin.queueLint", { symbol, kind }),
queueLintAll: (input: { kind: 'sec-lint-holders' | 'sec-lint-insiders' }) =>
trpcMutate<{ total: number; results: LintResult[] }>("admin.queueLintAll", input),
xCredentialsStatus: () => trpcQuery<XCredentialsStatus>("admin.xCredentialsStatus"),
xCredentialsSet: (ct0: string, auth_token: string) => trpcMutate<{ ok: boolean }>("admin.xCredentialsSet", { ct0, auth_token }),
fredKeyStatus: () => trpcQuery<{ configured: boolean }>("admin.fredKeyStatus"),
fredKeySet: (apiKey: string) => trpcMutate<{ ok: boolean }>("admin.fredKeySet", { apiKey }),
xAccountsList: () => trpcQuery<XAccountRow[]>("admin.xAccountsList"),
xAccountAdd: (symbol: string, handle: string, label?: string) => trpcMutate<{ id: string }>("admin.xAccountAdd", { symbol, handle, ...(label ? { label } : {}) }),
xAccountRemove: (id: string) => trpcMutate<{ ok: boolean }>("admin.xAccountRemove", { id }),
xPrune: (olderThanDays?: number) => trpcMutate<{ deleted: number }>("admin.xPrune", { olderThanDays: olderThanDays ?? 30 }),
pendingUsers: () => trpcQuery<Array<{ id: string; email: string; complexity: string; created_at: string }>>("admin.pendingUsers"),
approveUser: (userId: string) => trpcMutate<{ ok: boolean }>("admin.approveUser", { userId }),
rejectUser: (userId: string) => trpcMutate<{ ok: boolean }>("admin.rejectUser", { userId }),
dataQualityList: (input?: { symbol?: string; kind?: 'institution_filings' | 'insider_transactions' }) =>
trpcQuery<DataQualityRow[]>("admin.dataQualityList", input ?? {}),
serverRestart: (target: 'backend' | 'frontend' | 'all' = 'all') =>
trpcMutate<{ ok: boolean; backend: string; frontend: string }>("admin.serverRestart", { target }),
},
};
@@ -301,6 +665,112 @@ export interface PortfolioHolding {
added_at: string;
}
export type OptionRight = "call" | "put";
export type OptionSide = "long" | "short";
export type OptionRole =
| "long_call"
| "long_put"
| "covered_call"
| "cash_secured_put"
| "other";
export interface PortfolioOptionLeg {
id: string;
underlying: string;
right: OptionRight;
side: OptionSide;
strike: number;
expiry: string;
contracts: number;
premium: number;
multiplier: number;
role: OptionRole;
acquired_at: string;
note: string | null;
}
export interface AddOptionLegInput {
underlying: string;
right: OptionRight;
side: OptionSide;
strike: number;
expiry: string;
contracts: number;
premium: number;
multiplier?: number;
role?: OptionRole;
note?: string;
}
export interface OptionRiskSummary {
legsCount: number;
premiumAtRiskUsd: number;
cashReservedUsd: number;
creditReceivedUsd: number;
capitalCommittedUsd: number;
uncoveredShortCallCount: number;
byUnderlying: Record<
string,
{
premiumAtRiskUsd: number;
cashReservedUsd: number;
creditReceivedUsd: number;
legs: number;
uncoveredShortCalls: number;
}
>;
}
export interface SizingLayer {
name: string;
value: number;
explanation: string;
}
export interface SizingResult {
shares: number;
layers: SizingLayer[];
explanations: string[];
blocked: boolean;
blockReason: string | null;
override: "macro" | "cluster" | null;
halted?: boolean;
footer?: string;
}
export interface RiskRecommendation {
id: string;
tradeOff: string;
explanation: string;
severity: "warning" | "info";
}
export interface RiskPostureResult {
asymmetry: number;
clusterExposure: Record<string, number>;
clusterCaps?: Record<string, number>;
drawdownVsTolerance: {
currentDrawdownPct: number;
tolerancePct: number;
status: -1 | 0 | 1;
};
halted: boolean;
recommendedActions: RiskRecommendation[];
halt: { haltedUntil: string; triggeredBy: string; ts: string } | null;
holdingsCount: number;
totalExposureUsd: number;
options?: OptionRiskSummary;
footer: string;
}
export interface SourceCooldown {
source: string;
until: string | null;
remainingMs: number;
consecutiveHits: number;
active: boolean;
}
export interface QueueStatus {
paused: boolean;
queued: number;
@@ -312,6 +782,8 @@ export interface QueueStatus {
last_error: string | undefined;
counts: Record<string, number>;
lastErrors: Array<{ key: string; error: string | null }>;
/** Active vendor cool-downs after 429 (ADR-0009). */
sourceCooldowns?: SourceCooldown[];
}
export interface ScheduleEntry {