feat: dealer flow, mirror portfolio (M21), options convexity, FINRA short interest, alert producers, vendor gate
CI / Test & Type-Check (push) Canceled after 0s
CI / Test & Type-Check (push) Canceled after 0s
Snapshot of in-progress module work across multiple slices: - Dealer Flow: dealerExposureEngine, dealerMapService, dealerMapExplain, dealerMapIntegrity, dealerMapReplay, dealerStudyEngine, hanStyleLevels - Mirror Portfolio (M21): fundRepository, captureIngest, mirrorAlertProducers, fund holdings strip, live book, position capture ingest - Options: BSM, NormalizedOptionSurface types, OptionsChainRouter, ConvexityGate, option legs panel - Alert producers: vixLevel, rotation, thesis, unlock, portfolioRisk, mirror (fund_capture, fund_13f, mirror_diff) - FINRA short interest adapter + queue integration - SEC company tickers adapter + ingest (symbol search index seed) - Vendor gate (rate-limit-first data plane, ADR-0009) - CUSIP registry, reverse 13F refresh, stock float service - LRU cache, portfolio backtest engine - Frontend: dealer-flow, funds, journal, lab, monitor, plan, portfolio, reports, screener, strategies, theses, guided-start, exits, more pages - Volume profile, workspace profile, visibility-aware poll - ADRs 0010 (mirror math not advice), 0011 (symbol search index) - VENDOR_INTEGRATIONS.md, END_USER_TEST.md - .gitignore: exclude DBs, .DS_Store, local config, agent scratch
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* App shell scrolls inside <main data-app-scroll>, not the window.
|
||||
* Next.js Link scroll restoration and same-route nav clicks leave that pane mid-page.
|
||||
* Cross-page: LayoutShell remounts but focus / history restore can re-nudge scroll after paint.
|
||||
*/
|
||||
|
||||
const SCROLL_RETRY_MS = [0, 16, 50, 100, 200, 400, 800] as const;
|
||||
|
||||
export function scrollAppMainToTop(): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const nodes = document.querySelectorAll<HTMLElement>("[data-app-scroll]");
|
||||
nodes.forEach((el) => {
|
||||
el.scrollTop = 0;
|
||||
el.scrollLeft = 0;
|
||||
});
|
||||
// Fallback: first main in the shell if attribute missing on an older paint.
|
||||
if (nodes.length === 0) {
|
||||
const main = document.querySelector("main");
|
||||
if (main instanceof HTMLElement) {
|
||||
main.scrollTop = 0;
|
||||
main.scrollLeft = 0;
|
||||
}
|
||||
}
|
||||
window.scrollTo(0, 0);
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
}
|
||||
|
||||
/** Fire immediately + delayed passes (beat focus / Next restore / layout). */
|
||||
export function scrollAppMainToTopSoon(): void {
|
||||
scrollAppMainToTop();
|
||||
if (typeof window === "undefined") return;
|
||||
requestAnimationFrame(() => {
|
||||
scrollAppMainToTop();
|
||||
requestAnimationFrame(scrollAppMainToTop);
|
||||
});
|
||||
for (const ms of SCROLL_RETRY_MS) {
|
||||
window.setTimeout(scrollAppMainToTop, ms);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Align an element to the top of the nested main scroller
|
||||
* (window.scrollIntoView cannot target overflow:auto <main>).
|
||||
*/
|
||||
export function scrollIdToMainTop(id: string): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
const main =
|
||||
document.querySelector<HTMLElement>("[data-app-scroll]") ??
|
||||
document.querySelector("main");
|
||||
const el = document.getElementById(id);
|
||||
if (!main || !el) return false;
|
||||
const mainRect = main.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
main.scrollTop += elRect.top - mainRect.top;
|
||||
window.scrollTo(0, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function scrollIdToMainTopSoon(id: string): void {
|
||||
const run = () => {
|
||||
scrollIdToMainTop(id);
|
||||
};
|
||||
run();
|
||||
if (typeof window === "undefined") return;
|
||||
requestAnimationFrame(() => {
|
||||
run();
|
||||
requestAnimationFrame(run);
|
||||
});
|
||||
for (const ms of SCROLL_RETRY_MS) {
|
||||
window.setTimeout(run, ms);
|
||||
}
|
||||
}
|
||||
|
||||
/** Disable browser history scroll restore (it fights nested main scroll). */
|
||||
export function disableHistoryScrollRestoration(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if ("scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual";
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
+20
-20
@@ -4,7 +4,7 @@
|
||||
// noun phrases such as the buy-zone estimate are allowed.
|
||||
export const UI_STRINGS = {
|
||||
appTitle: "Investor Flow",
|
||||
appSubtitle: "Educational investment research terminal",
|
||||
appSubtitle: "Investment research and portfolio tools",
|
||||
symbolPlaceholder: "Enter a symbol (e.g. NVDA)",
|
||||
loadingSnapshot: "Loading cached snapshot…",
|
||||
noData: "No cached data yet — the backend is fetching this in the background.",
|
||||
@@ -16,8 +16,8 @@ export const UI_STRINGS = {
|
||||
sparklineCaption: "Recent daily close — context for where price has been, not a signal to act on.",
|
||||
cryptoNotice: "Crypto — limited research module (SEC-derived ownership and filings do not apply).",
|
||||
staleBadge: "cached — refreshing",
|
||||
primaryRuleFooter:
|
||||
"Educational analysis, not investment advice. Verify the underlying data; you are responsible for your own decisions.",
|
||||
/** Single app-wide footnote. Keep off body copy. */
|
||||
primaryRuleFooter: "Educational observation only.",
|
||||
authEmailLabel: "Email",
|
||||
authPasswordLabel: "Password",
|
||||
signupButton: "Create account",
|
||||
@@ -44,19 +44,15 @@ export const UI_STRINGS = {
|
||||
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",
|
||||
portfolioTitle: "Holdings",
|
||||
portfolioCaption: "Symbol, shares, and average cost.",
|
||||
emptyPortfolio: "No holdings yet. Add symbol, shares, and average cost.",
|
||||
portfolioVsWatchlist: "",
|
||||
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.",
|
||||
"Record calls and puts on the book (long, covered call, cash-secured put). Premium is per share.",
|
||||
emptyOptionLegs:
|
||||
"No open option legs yet. Choose a role, then enter underlying, strike, expiry, contracts, and premium.",
|
||||
"No open option legs. Pick a role, then enter underlying, strike, expiry, contracts, and premium.",
|
||||
optionPremiumAtRiskLabel: "Premium at risk",
|
||||
optionCashReservedLabel: "Cash reserved (CSPs)",
|
||||
optionCreditLabel: "Credits received",
|
||||
@@ -70,6 +66,11 @@ export const UI_STRINGS = {
|
||||
volumeLabel: "Volume",
|
||||
volumeCaption:
|
||||
"Number of shares traded per day — relative size, not a directional cue.",
|
||||
volumeProfileLabel: "Volume by price",
|
||||
volumeProfileCaption:
|
||||
"Share volume by price from the visible bars (coarse daily/weekly profile - not options OI).",
|
||||
volumeProfilePoc: "POC",
|
||||
volumeProfileEmpty: "No volume in this range to build a profile.",
|
||||
rsiLabel: "Relative Strength Index (RSI)",
|
||||
rsiCaption:
|
||||
"Momentum oscillator comparing gain and loss magnitude over a rolling window.",
|
||||
@@ -88,15 +89,14 @@ export const UI_STRINGS = {
|
||||
"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",
|
||||
// Risk
|
||||
riskPostureTitle: "Risk",
|
||||
riskPostureSubtitle:
|
||||
"Your complete risk picture: holdings exposure, drawdown vs tolerance, clusters, asymmetry, and the gentle-halt circuit breaker - plus the sizing math cascade.",
|
||||
"Drawdown, exposure, concentration, and position size.",
|
||||
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.",
|
||||
"Equity is total account value. Peak is the high-water mark for drawdown.",
|
||||
sizingTitle: "Position size",
|
||||
sizingCaption: "Share count from risk % of equity and stop distance.",
|
||||
} as const;
|
||||
|
||||
export const UI_STRING_LIST: string[] = Object.values(UI_STRINGS);
|
||||
|
||||
+790
-36
@@ -13,6 +13,10 @@ export interface Quote {
|
||||
change?: number | null;
|
||||
changePercent?: number | null;
|
||||
iv?: number | null;
|
||||
/** Session of the displayed last print. */
|
||||
session?: 'regular' | 'pre' | 'post' | null;
|
||||
/** RTH last when price is an extended print. */
|
||||
regularPrice?: number | null;
|
||||
}
|
||||
export interface PriceCandle {
|
||||
ts: string;
|
||||
@@ -40,6 +44,25 @@ export interface SymbolMeta {
|
||||
peers?: string[] | null;
|
||||
}
|
||||
|
||||
export interface SymbolSearchResult {
|
||||
symbol: string;
|
||||
name: string | null;
|
||||
sector: string | null;
|
||||
industry: string | null;
|
||||
exchange: string | null;
|
||||
ticker_kind: string;
|
||||
cik: string | null;
|
||||
}
|
||||
|
||||
export interface FundHoldersResult {
|
||||
symbol: string;
|
||||
tracked: Array<{
|
||||
fundId: string; fundName: string; shares: number | null; valueUsd: number | null;
|
||||
asOf: string; source: string; weightPct: number | null;
|
||||
}>;
|
||||
institutionCount: number;
|
||||
}
|
||||
|
||||
export interface XPost {
|
||||
post_id: string;
|
||||
author_handle: string;
|
||||
@@ -58,6 +81,261 @@ export interface XAccountRow {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ─── Dealer Flow types ─────────────────────────────────────────────────────
|
||||
|
||||
export interface DealerMapCell {
|
||||
strike: number;
|
||||
expiry: string;
|
||||
gex: number;
|
||||
vex: number;
|
||||
oi: number;
|
||||
volume: number;
|
||||
polarity: "pos" | "neg" | "near_zero";
|
||||
isKing: boolean;
|
||||
}
|
||||
|
||||
export interface DealerMapLevels {
|
||||
king: { strike: number; expiry: string; gex: number } | null;
|
||||
gatekeepers: Array<{ strike: number; absGex: number }>;
|
||||
flip: number | null;
|
||||
callWall: number | null;
|
||||
putWall: number | null;
|
||||
airPockets: Array<{ from: number; to: number }>;
|
||||
clusters: Array<{ strikes: number[]; absGexSum: number }>;
|
||||
}
|
||||
|
||||
export interface DealerMapEducator {
|
||||
headline: string;
|
||||
regime: string;
|
||||
levels: string[];
|
||||
scenario: string;
|
||||
patterns: string[];
|
||||
questions: string[];
|
||||
caveats: string[];
|
||||
footer: string;
|
||||
}
|
||||
|
||||
export interface DealerMapPayload {
|
||||
symbol: string;
|
||||
spot: number;
|
||||
asOf: string;
|
||||
delayNote: string;
|
||||
delaySeconds: number | null;
|
||||
oiFreshness: string;
|
||||
providerId: string;
|
||||
convention: string;
|
||||
unit: string;
|
||||
computation: string;
|
||||
expiries: string[];
|
||||
cells: DealerMapCell[];
|
||||
byStrike: Array<{ strike: number; gex: number; vex: number; absGex: number }>;
|
||||
netVexByStrike: Array<{ strike: number; vex: number }>;
|
||||
levels: DealerMapLevels;
|
||||
regimeAtSpot: "positive" | "negative" | "mixed";
|
||||
scenario: string;
|
||||
patterns: Array<{ id: string; confidence?: number; [k: string]: unknown }>;
|
||||
velocity?: {
|
||||
asOfPrev: string;
|
||||
nodes: Array<{ strike: number; growing: boolean; deltaAbsGex: number }>;
|
||||
growingNearAirPocket: boolean;
|
||||
} | null;
|
||||
opexHints: { isNearOpex: boolean; note: string };
|
||||
coveragePct: number;
|
||||
bsmFillPct: number;
|
||||
quality?: {
|
||||
greekQuality: "ok" | "degraded" | "poor";
|
||||
oiEligible: number;
|
||||
included: number;
|
||||
skippedBadIv: number;
|
||||
bsmFills: number;
|
||||
fallbackIvUses: number;
|
||||
bsmFillPct: number;
|
||||
badIvSkipPct: number;
|
||||
fallbackIvPct: number;
|
||||
notes: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DealerIntegrityReport {
|
||||
status: "complete" | "degraded" | "incomplete";
|
||||
checks: Array<{ id: string; level: "hard" | "soft"; ok: boolean; detail: string }>;
|
||||
hardFailed: string[];
|
||||
softFailed: string[];
|
||||
metrics: {
|
||||
expiries: number;
|
||||
oiEligible: number;
|
||||
included: number;
|
||||
cells: number;
|
||||
byStrike: number;
|
||||
nearSpotOiStrikes: number;
|
||||
minOiStrikesPerExpiry: number;
|
||||
includePct: number;
|
||||
badIvSkipPct: number;
|
||||
bsmFillPct: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Han-style day script under the active GEX convention. */
|
||||
export interface HanStyleLevels {
|
||||
symbol: string;
|
||||
spot: number;
|
||||
asOf: string;
|
||||
convention: string;
|
||||
script: string;
|
||||
chips: string[];
|
||||
longSupport: number | null;
|
||||
shortKing: number | null;
|
||||
resistance: number | null;
|
||||
opexPath: number[];
|
||||
regimeLabel: "long_gamma" | "short_gamma" | "mixed";
|
||||
}
|
||||
|
||||
export interface DealerMapGetResult {
|
||||
map: DealerMapPayload | null;
|
||||
educator: DealerMapEducator | null;
|
||||
/** Support / resist / short king / OPEX path (updates with Classic vs Dealer toggle). */
|
||||
hanScript?: HanStyleLevels | null;
|
||||
status: "ok" | "stale" | "pending" | "empty" | "incomplete";
|
||||
isStale: boolean;
|
||||
scheduled: boolean;
|
||||
providerId: string;
|
||||
message?: string;
|
||||
integrity?: DealerIntegrityReport;
|
||||
}
|
||||
|
||||
export interface DealerMapLevelsResult {
|
||||
symbol: string;
|
||||
status: string;
|
||||
scheduled: boolean;
|
||||
levels: DealerMapLevels | null;
|
||||
spot: number | null;
|
||||
delayNote: string | null;
|
||||
providerId: string;
|
||||
regimeAtSpot?: string;
|
||||
scenario?: string;
|
||||
patterns?: string[];
|
||||
}
|
||||
|
||||
export interface DealerMapScenarioResult {
|
||||
symbol: string;
|
||||
status: string;
|
||||
scenario: string;
|
||||
regimeAtSpot: string;
|
||||
patterns: Array<{ id: string; confidence?: number }>;
|
||||
educator: DealerMapEducator | null;
|
||||
delayNote: string | null;
|
||||
providerId: string;
|
||||
}
|
||||
|
||||
export interface DealerMapVelocityResult {
|
||||
symbol: string;
|
||||
status: string;
|
||||
velocity: DealerMapPayload["velocity"];
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface DealerMapExplainResult {
|
||||
ok: boolean;
|
||||
source: "llm" | "layer0_fallback" | "rate_limited" | "no_endpoint" | "error";
|
||||
headline: string;
|
||||
sections: {
|
||||
whatMapShows: string;
|
||||
keyLevels: string[];
|
||||
scenarioInPlainEnglish: string;
|
||||
questionsToAsk: string[];
|
||||
caveats: string[];
|
||||
dataFreshness: string;
|
||||
};
|
||||
model?: string;
|
||||
cached?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Study Desk practice setups. */
|
||||
export interface DealerStudyProposal {
|
||||
hypothesisId: string;
|
||||
title: string;
|
||||
bias: "educational_long" | "educational_short" | "educational_range";
|
||||
mapReading: string;
|
||||
studyQuestion: string;
|
||||
entry: number;
|
||||
invalidation: number;
|
||||
target: number;
|
||||
horizonDays: number;
|
||||
confidence: number;
|
||||
disclaimer: string;
|
||||
histWinRate?: number | null;
|
||||
histN?: number;
|
||||
histAvgScore?: number | null;
|
||||
mentorWinRate?: number | null;
|
||||
mentorN?: number;
|
||||
mentorClaimType?: string | null;
|
||||
rankScore?: number;
|
||||
}
|
||||
|
||||
export interface DealerStudyRow {
|
||||
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;
|
||||
confidence: number | null;
|
||||
status: string;
|
||||
grade: string | null;
|
||||
score: number | null;
|
||||
grade_summary: string | null;
|
||||
grade_path: string | null;
|
||||
graded_at: string | null;
|
||||
logged_at: string;
|
||||
disclaimer: string;
|
||||
}
|
||||
|
||||
export interface DealerStudyScorecardRow {
|
||||
hypothesisId: string;
|
||||
n: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
expired: number;
|
||||
incomplete: number;
|
||||
winRate: number | null;
|
||||
avgScore: number | null;
|
||||
}
|
||||
|
||||
export interface MentorCallRow {
|
||||
id: string;
|
||||
handle: string;
|
||||
post_id: string;
|
||||
posted_at: string | null;
|
||||
symbol: string | null;
|
||||
bias: string;
|
||||
claim_type: string;
|
||||
entry: number;
|
||||
invalidation: number;
|
||||
target: number;
|
||||
horizon_days: number;
|
||||
excerpt: string | null;
|
||||
extract_confidence: number | null;
|
||||
status: string;
|
||||
grade: string | null;
|
||||
score: number | null;
|
||||
grade_summary: string | null;
|
||||
logged_at: string;
|
||||
}
|
||||
|
||||
export interface UserLlmStatus {
|
||||
configured: boolean;
|
||||
baseUrl: string | null;
|
||||
model: string | null;
|
||||
keyHint: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface XCredentialsStatus {
|
||||
configured: boolean;
|
||||
healthy: 'healthy' | 'degraded';
|
||||
@@ -71,7 +349,11 @@ export interface SectorRotationItem {
|
||||
/** Legacy: inflow ≈ leading, outflow ≈ lagging (RS vs SPY, not abs flow). */
|
||||
group: 'outflow' | 'inflow';
|
||||
subGroup: string;
|
||||
kind?: 'sector' | 'style' | 'region' | 'thematic';
|
||||
kind?: 'sector' | 'style' | 'region' | 'thematic' | 'custom' | 'auto';
|
||||
/** True when this ETF is a sector/theme proxy for at least one open portfolio holding. */
|
||||
portfolioLinked?: boolean;
|
||||
/** Holding symbols that map into this ETF (Auto filter provenance). */
|
||||
portfolioHoldings?: string[];
|
||||
leadership?: 'leading' | 'lagging' | 'inline' | 'unknown';
|
||||
earlyWatch?: boolean;
|
||||
rank1M?: number | null;
|
||||
@@ -82,11 +364,13 @@ export interface SectorRotationItem {
|
||||
threeMonth: number | null;
|
||||
sixMonth: number | null;
|
||||
oneYear: number | null;
|
||||
oneDay?: number | null;
|
||||
rsOneWeek?: number | null;
|
||||
rsOneMonth?: number | null;
|
||||
rsThreeMonth?: number | null;
|
||||
rsSixMonth?: number | null;
|
||||
rsOneYear?: number | null;
|
||||
rsOneDay?: number | null;
|
||||
}
|
||||
|
||||
export interface AlertEventRow {
|
||||
@@ -115,6 +399,33 @@ export interface AlertSubscriptionRow {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AlertTypeToggleRow {
|
||||
type: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ProducerRunStatusRow {
|
||||
producer: string;
|
||||
frequency: string;
|
||||
lastRunAt: string | null;
|
||||
lastEventsFired: number;
|
||||
lastDurationMs: number;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface AlertStatusRow {
|
||||
producers: ProducerRunStatusRow[];
|
||||
subscriptionsByType: Array<{ alertType: string; enabledCount: number }>;
|
||||
freshness: {
|
||||
vixLevelAgeSec: number | null;
|
||||
latestScFilingAt: string | null;
|
||||
latestInsiderAt: string | null;
|
||||
oldestQuoteAgeSec: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SmtpConfigRow {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -173,6 +484,8 @@ export interface MarketCondition {
|
||||
treasury2Y: number | null;
|
||||
curve10y2y: number | null;
|
||||
unemployment: number | null;
|
||||
consumerSentiment?: number | null;
|
||||
context?: Record<string,string>;
|
||||
};
|
||||
risks: Array<{
|
||||
id: string;
|
||||
@@ -368,15 +681,38 @@ export interface AuthUser {
|
||||
convexityPosture?: string;
|
||||
onboarded?: boolean;
|
||||
modules?: string[];
|
||||
experienceStage?: string | null;
|
||||
goal?: string | null;
|
||||
horizon?: string | null;
|
||||
density?: string | null;
|
||||
jargonComfort?: string | null;
|
||||
}
|
||||
|
||||
// 8-second timeout for all tRPC calls to prevent hung requests.
|
||||
// Default short timeout for normal cache/UI calls. LLM paths need much longer
|
||||
// (Ornith reasoning models often take 30–120s).
|
||||
const TRPC_TIMEOUT_MS = 8_000;
|
||||
/** OpenAI-compatible explain / test against local reasoning models. */
|
||||
const TRPC_LLM_TIMEOUT_MS = 180_000;
|
||||
|
||||
async function parse<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`tRPC ${res.status}: ${body}`);
|
||||
// Prefer structured proxy / tRPC error message over raw HTML "Internal Server Error".
|
||||
try {
|
||||
const j = JSON.parse(body) as {
|
||||
error?: { message?: string; data?: { code?: string } };
|
||||
message?: string;
|
||||
};
|
||||
const msg =
|
||||
j?.error?.message ??
|
||||
j?.message ??
|
||||
(body.trim() || res.statusText);
|
||||
throw new Error(`tRPC ${res.status}: ${msg}`);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.startsWith("tRPC ")) throw e;
|
||||
const snippet = body.replace(/\s+/g, " ").trim().slice(0, 200);
|
||||
throw new Error(`tRPC ${res.status}: ${snippet || res.statusText}`);
|
||||
}
|
||||
}
|
||||
const json = await res.json();
|
||||
if (json?.error) {
|
||||
@@ -385,9 +721,13 @@ async function parse<T>(res: Response): Promise<T> {
|
||||
return json.result.data as T;
|
||||
}
|
||||
|
||||
async function trpcQuery<T>(procedure: string, input?: unknown): Promise<T> {
|
||||
async function trpcQuery<T>(
|
||||
procedure: string,
|
||||
input?: unknown,
|
||||
timeoutMs: number = TRPC_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TRPC_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const search = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : "";
|
||||
const res = await fetch(`/api/trpc/${procedure}${search}`, {
|
||||
@@ -398,7 +738,7 @@ async function trpcQuery<T>(procedure: string, input?: unknown): Promise<T> {
|
||||
return parse<T>(res);
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
throw new Error(`Request timed out (${TRPC_TIMEOUT_MS / 1000}s)`);
|
||||
throw new Error(`Request timed out (${Math.round(timeoutMs / 1000)}s)`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
@@ -406,9 +746,13 @@ async function trpcQuery<T>(procedure: string, input?: unknown): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
async function trpcMutate<T>(procedure: string, input: unknown): Promise<T> {
|
||||
async function trpcMutate<T>(
|
||||
procedure: string,
|
||||
input: unknown,
|
||||
timeoutMs: number = TRPC_TIMEOUT_MS,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TRPC_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(`/api/trpc/${procedure}`, {
|
||||
method: "POST",
|
||||
@@ -420,7 +764,7 @@ async function trpcMutate<T>(procedure: string, input: unknown): Promise<T> {
|
||||
return parse<T>(res);
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
throw new Error(`Request timed out (${TRPC_TIMEOUT_MS / 1000}s)`);
|
||||
throw new Error(`Request timed out (${Math.round(timeoutMs / 1000)}s)`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
@@ -501,6 +845,8 @@ export interface FilingsSearchResult {
|
||||
export const api = {
|
||||
market: {
|
||||
snapshot: (symbol: string) => trpcQuery<Snapshot>("market.snapshot", { symbol }),
|
||||
/** Batched snapshot for multiple symbols — reduces N+1 API calls */
|
||||
snapshots: (symbols: string[]) => trpcQuery<Array<{ symbol: string; quote: Snapshot['quote'] | null; candles: PriceCandle[] | null; sector: SymbolMeta | null; stale: { quote: boolean; candles: boolean; sector: boolean } }>>("market.snapshots", { symbols }),
|
||||
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) =>
|
||||
@@ -509,6 +855,12 @@ export const api = {
|
||||
manufacturingPmi: () => trpcQuery<ManufacturingPmiData>("market.manufacturingPmi"),
|
||||
condition: () => trpcQuery<MarketCondition>("market.condition"),
|
||||
rotation: () => trpcQuery<RotationData>("market.rotation"),
|
||||
addCustomEtf: (symbol: string, opts?: { name?: string; group?: string }) =>
|
||||
trpcMutate<{ added: boolean }>("market.addCustomEtf", { symbol, ...opts }),
|
||||
removeCustomEtf: (symbol: string) =>
|
||||
trpcMutate<{ removed: boolean }>("market.removeCustomEtf", { symbol }),
|
||||
listCustomEtfs: () =>
|
||||
trpcQuery<Array<{ symbol: string; name: string; grp: string; created_at: string }>>("market.listCustomEtfs"),
|
||||
rotationCheckForAlert: () => trpcMutate<RotationAlertCheckResult>("market.rotationCheckForAlert", {}),
|
||||
seasonality: (symbol?: string) =>
|
||||
trpcQuery<SeasonalityData>("market.seasonality", symbol ? { symbol } : {}),
|
||||
@@ -534,6 +886,96 @@ export const api = {
|
||||
form4_tx: (cik: string, accession: string) =>
|
||||
trpcQuery<{ transactions: Form4Result }>("edgar.form4_tx", { cik, accession }),
|
||||
},
|
||||
funds: {
|
||||
list: () => trpcQuery<Array<{
|
||||
id: string; ci_key: string; fund_name: string; manager_name: string;
|
||||
x_handle: string | null; paywall_status: string; enabled: boolean;
|
||||
}>>("funds.list", {}),
|
||||
get: (input: { id: string }) => trpcQuery<{
|
||||
id: string; ci_key: string; fund_name: string; manager_name: string;
|
||||
x_handle: string | null; paywall_status: string; enabled: boolean;
|
||||
} | null>("funds.get", input),
|
||||
liveBook: (input: { fundId: string }) =>
|
||||
trpcQuery<Array<{
|
||||
symbol: string; shares: number | null; value_usd: number | null;
|
||||
cost_basis: number | null; as_of: string; source: string; evidence_url: string | null;
|
||||
notes: string | null; post_text: string | null;
|
||||
}>>("funds.liveBook", input),
|
||||
adminCreate: (input: {
|
||||
ci_key: string; fund_name: string; manager_name: string; x_handle?: string;
|
||||
paywall_status?: "unknown" | "open" | "paywalled"; enabled?: boolean;
|
||||
}) => trpcMutate<{ id: string; ci_key: string; fund_name: string; manager_name: string; x_handle: string | null; paywall_status: string; enabled: boolean }>("funds.adminCreate", input),
|
||||
adminDelete: (input: { id: string }) => trpcMutate<{ deleted: boolean }>("funds.adminDelete", input),
|
||||
adminSetEnabled: (input: { id: string; enabled: boolean }) => trpcMutate<{ updated: boolean }>("funds.adminSetEnabled", input),
|
||||
adminSync13f: (input: { fundId: string }) => trpcMutate<{
|
||||
fetched_filings: number; holdings_seen: number; symbols_upserted: number;
|
||||
records_inserted: number; unresolved: Array<{ cusip: string; issuerName: string }>;
|
||||
}>("funds.adminSync13f", input),
|
||||
adminIngestCaptures: (input: { fundId: string }) => trpcMutate<{
|
||||
fundId: string; postsScanned: number; captures: number; inserted: number;
|
||||
refreshed: number; claims: number; skipped: number; disabled: boolean;
|
||||
}>("funds.adminIngestCaptures", input),
|
||||
},
|
||||
symbols: {
|
||||
search: (input: { q: string; limit?: number }) => trpcQuery<{
|
||||
q: string;
|
||||
results: Array<{
|
||||
symbol: string; name: string | null; sector: string | null; industry: string | null;
|
||||
exchange: string | null; ticker_kind: string; cik: string | null;
|
||||
}>;
|
||||
}>("symbols.search", input),
|
||||
holders: (input: { symbol: string }) => trpcQuery<{
|
||||
symbol: string;
|
||||
tracked: Array<{
|
||||
fundId: string; fundName: string; shares: number | null; valueUsd: number | null;
|
||||
asOf: string; source: string; weightPct: number | null;
|
||||
}>;
|
||||
institutionCount: number;
|
||||
}>("symbols.holders", input),
|
||||
},
|
||||
mirror: {
|
||||
diff: (input: {
|
||||
fundId: string; base?: number; locked?: boolean;
|
||||
minPositionUsd?: number; minWeightPct?: number; tickSize?: number;
|
||||
}) => trpcQuery<{
|
||||
base: number; locked: boolean; book_value: number; placed_value: number;
|
||||
rows: Array<{
|
||||
symbol: string; weight: number; fund_avg_cost: number | null; target_value: number;
|
||||
target_qty: number | null; actual_qty: number; actual_value: number;
|
||||
delta_qty: number; delta_value: number; message: string; excluded: boolean;
|
||||
}>;
|
||||
excluded: Array<{ symbol: string; weight: number; fund_avg_cost: number | null; target_value: number; target_qty: number | null; actual_qty: number; actual_value: number; delta_qty: number; delta_value: number; message: string; excluded: boolean }>;
|
||||
excluded_tail: { count: number; value_usd: number } | null;
|
||||
}>("mirror.diff", input),
|
||||
},
|
||||
sectorCrosslink: {
|
||||
confirm: (input: { strategyId: string; sector: string }) => trpcQuery<{
|
||||
strategyId: string;
|
||||
sector: string;
|
||||
matchedSymbols: Array<{
|
||||
symbol: string; convictionStrength: number; conditionsFired: string[]; oneTapWorkbenchLink: string;
|
||||
}>;
|
||||
rotationSignal: { fromSector: string; toSector: string; confidence: number; date: string } | null;
|
||||
sectorConfirmationReason: string;
|
||||
educationalDisclaimer: string;
|
||||
}>("sectorCrosslink.confirm", input),
|
||||
},
|
||||
reddit: {
|
||||
subreddit: (input: { subreddit: string; limit?: number }) => trpcQuery<{
|
||||
posts: Array<{
|
||||
post_id: string; author_handle: string; subreddit: string; body_text: string | null;
|
||||
posted_at: string; engagement: number; sentiment_score?: number | null; attribution?: string | null;
|
||||
}>;
|
||||
isStale: boolean;
|
||||
}>("reddit.subreddit", input),
|
||||
search: (input: { q: string; limit?: number }) => trpcQuery<{
|
||||
posts: Array<{
|
||||
post_id: string; author_handle: string; subreddit: string; body_text: string | null;
|
||||
posted_at: string; engagement: number; sentiment_score?: number | null; attribution?: string | null;
|
||||
}>;
|
||||
isStale: boolean;
|
||||
}>("reddit.search", input),
|
||||
},
|
||||
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 } : {}) }),
|
||||
@@ -544,9 +986,39 @@ export const api = {
|
||||
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),
|
||||
starter: (input?: { density?: "focused" | "standard" | "full"; experienceStage?: "never_invested" | "some_stocks" | "active_self_directed" }) =>
|
||||
trpcQuery<{ watchlist: StarterEntry[]; density: string; disclaimer: string }>("onboarding.starter", input ?? {}),
|
||||
complete: (input: {
|
||||
experienceStage?: "never_invested" | "some_stocks" | "active_self_directed";
|
||||
goal?: "grow" | "income" | "protect";
|
||||
horizon?: "short" | "medium" | "long";
|
||||
density?: "focused" | "standard" | "full";
|
||||
jargonComfort?: "plain" | "mixed" | "technical";
|
||||
complexity?: Complexity;
|
||||
drawdownTolerancePct?: number;
|
||||
firstWatchlistSymbols?: string[];
|
||||
portfolio?: PortfolioEntry[];
|
||||
}) =>
|
||||
trpcMutate<{ ok: boolean; watchlistId: string; density?: string; complexity?: string; experienceStage?: string }>(
|
||||
"onboarding.complete",
|
||||
input,
|
||||
),
|
||||
updateProfile: (input: {
|
||||
experienceStage?: "never_invested" | "some_stocks" | "active_self_directed";
|
||||
goal?: "grow" | "income" | "protect";
|
||||
horizon?: "short" | "medium" | "long";
|
||||
density?: "focused" | "standard" | "full";
|
||||
jargonComfort?: "plain" | "mixed" | "technical";
|
||||
}) =>
|
||||
trpcMutate<{
|
||||
ok: boolean;
|
||||
experienceStage: string;
|
||||
goal: string;
|
||||
horizon: string;
|
||||
density: string;
|
||||
jargonComfort: string;
|
||||
complexity: string;
|
||||
}>("onboarding.updateProfile", input),
|
||||
},
|
||||
watchlists: {
|
||||
list: () => trpcQuery<WatchlistEntry[]>("watchlists.list"),
|
||||
@@ -569,9 +1041,148 @@ export const api = {
|
||||
greeks: (symbol: string, opts?: { expiry?: string; strike?: number }) =>
|
||||
trpcQuery<{ symbol: string; greeks: OptionGreeks | null; strike?: number; right?: "call" | "put"; lastPrice?: number; impliedVolatility?: number }>("options.greeks", { symbol, ...opts }),
|
||||
},
|
||||
dealerMap: {
|
||||
get: (
|
||||
symbol: string,
|
||||
opts?: {
|
||||
forceSchedule?: boolean;
|
||||
maxExpiries?: number;
|
||||
convention?: "classic_call_pos_put_neg" | "dealer_inventory";
|
||||
},
|
||||
) => trpcQuery<DealerMapGetResult>("dealerMap.get", { symbol, ...opts }),
|
||||
levels: (symbol: string) =>
|
||||
trpcQuery<DealerMapLevelsResult>("dealerMap.levels", { symbol }),
|
||||
scenario: (symbol: string) =>
|
||||
trpcQuery<DealerMapScenarioResult>("dealerMap.scenario", { symbol }),
|
||||
velocity: (symbol: string) =>
|
||||
trpcQuery<DealerMapVelocityResult>("dealerMap.velocity", { symbol }),
|
||||
explain: (symbol: string) =>
|
||||
trpcMutate<DealerMapExplainResult>(
|
||||
"dealerMap.explain",
|
||||
{ symbol },
|
||||
TRPC_LLM_TIMEOUT_MS,
|
||||
),
|
||||
},
|
||||
dealerStudy: {
|
||||
propose: (symbol: string, opts?: { useMentorHistory?: boolean }) =>
|
||||
trpcQuery<{
|
||||
symbol: string;
|
||||
proposals: DealerStudyProposal[];
|
||||
message: string | null;
|
||||
status: string;
|
||||
useMentorHistory?: boolean;
|
||||
}>("dealerStudy.propose", {
|
||||
symbol,
|
||||
useMentorHistory: opts?.useMentorHistory === true,
|
||||
}),
|
||||
log: (input: DealerStudyProposal & { symbol: string }) =>
|
||||
trpcMutate<{ ok: boolean; id: string; loggedAt: string }>("dealerStudy.log", {
|
||||
symbol: input.symbol,
|
||||
hypothesisId: input.hypothesisId,
|
||||
title: input.title,
|
||||
bias: input.bias,
|
||||
mapReading: input.mapReading,
|
||||
studyQuestion: input.studyQuestion,
|
||||
entry: input.entry,
|
||||
invalidation: input.invalidation,
|
||||
target: input.target,
|
||||
horizonDays: input.horizonDays,
|
||||
confidence: input.confidence,
|
||||
disclaimer: input.disclaimer,
|
||||
}),
|
||||
list: (opts?: { symbol?: string; limit?: number }) =>
|
||||
trpcQuery<DealerStudyRow[]>("dealerStudy.list", opts ?? {}),
|
||||
grade: (id: string) =>
|
||||
trpcMutate<{
|
||||
ok: boolean;
|
||||
grade: string;
|
||||
score: number;
|
||||
summary: string;
|
||||
pathNotes: string[];
|
||||
status: string;
|
||||
}>("dealerStudy.grade", { id }),
|
||||
gradeDue: () =>
|
||||
trpcMutate<{ ok: boolean; considered: number; graded: number; incomplete: number }>(
|
||||
"dealerStudy.gradeDue",
|
||||
{},
|
||||
),
|
||||
scorecard: () => trpcQuery<DealerStudyScorecardRow[]>("dealerStudy.scorecard"),
|
||||
promoteToJournal: (studyId: string) =>
|
||||
trpcMutate<{ ok: boolean; tradeId: string; message: string }>("dealerStudy.promoteToJournal", {
|
||||
studyId,
|
||||
}),
|
||||
exportCsv: (opts?: { symbol?: string; limit?: number }) =>
|
||||
trpcQuery<{ filename: string; csv: string; rowCount: number }>(
|
||||
"dealerStudy.exportCsv",
|
||||
opts ?? {},
|
||||
),
|
||||
},
|
||||
mentorLedger: {
|
||||
importFromHarvest: (opts?: { handle?: string; maxFiles?: number }) =>
|
||||
trpcMutate<{
|
||||
ok: boolean;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
filesSeen?: number;
|
||||
message: string | null;
|
||||
}>("mentorLedger.importFromHarvest", opts ?? {}),
|
||||
list: (opts?: { handle?: string; status?: string; limit?: number }) =>
|
||||
trpcQuery<MentorCallRow[]>("mentorLedger.list", opts ?? {}),
|
||||
confirm: (input: {
|
||||
id: string;
|
||||
entry?: number;
|
||||
invalidation?: number;
|
||||
target?: number;
|
||||
bias?: "educational_long" | "educational_short" | "educational_range";
|
||||
horizonDays?: number;
|
||||
symbol?: string;
|
||||
}) => trpcMutate<{ ok: boolean }>("mentorLedger.confirm", input),
|
||||
discard: (id: string) => trpcMutate<{ ok: boolean }>("mentorLedger.discard", { id }),
|
||||
grade: (id: string) =>
|
||||
trpcMutate<{
|
||||
ok: boolean;
|
||||
grade: string;
|
||||
score: number;
|
||||
summary: string;
|
||||
status: string;
|
||||
}>("mentorLedger.grade", { id }),
|
||||
gradeDue: () =>
|
||||
trpcMutate<{ ok: boolean; considered: number; graded: number; incomplete: number }>(
|
||||
"mentorLedger.gradeDue",
|
||||
{},
|
||||
),
|
||||
scorecard: (handle?: string) =>
|
||||
trpcQuery<
|
||||
Array<{
|
||||
handle: string;
|
||||
claimType: string;
|
||||
n: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
expired: number;
|
||||
incomplete: number;
|
||||
winRate: number | null;
|
||||
avgScore: number | null;
|
||||
}>
|
||||
>("mentorLedger.scorecard", handle ? { handle } : {}),
|
||||
},
|
||||
userLlm: {
|
||||
status: () => trpcQuery<UserLlmStatus>("userLlm.status"),
|
||||
upsertEndpoint: (input: { baseUrl: string; apiKey?: string; model?: string; keepExistingKey?: boolean }) =>
|
||||
trpcMutate<UserLlmStatus>("userLlm.upsertEndpoint", input),
|
||||
clear: () => trpcMutate<{ ok: boolean }>("userLlm.clear", {}),
|
||||
test: (input?: { baseUrl?: string; apiKey?: string; model?: string }) =>
|
||||
trpcMutate<{ ok: true; model: string; sample: string } | { ok: false; error: string }>(
|
||||
"userLlm.test",
|
||||
input ?? {},
|
||||
TRPC_LLM_TIMEOUT_MS,
|
||||
),
|
||||
},
|
||||
portfolio: {
|
||||
holdings: () => trpcQuery<PortfolioHolding[]>("portfolio.holdings"),
|
||||
addHolding: (symbol: string, shares: number, avgCost: number) => trpcMutate<{ created: boolean }>("portfolio.addHolding", { symbol, shares, avgCost }),
|
||||
updateHolding: (symbol: string, opts: { shares?: number; avgCost?: number }) =>
|
||||
trpcMutate<{ updated: boolean }>("portfolio.updateHolding", { symbol, ...opts }),
|
||||
removeHolding: (symbol: string) => trpcMutate<{ removed: boolean }>("portfolio.removeHolding", { symbol }),
|
||||
optionLegs: () => trpcQuery<PortfolioOptionLeg[]>("portfolio.optionLegs"),
|
||||
addOptionLeg: (input: AddOptionLegInput) =>
|
||||
@@ -605,8 +1216,6 @@ export const api = {
|
||||
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", {}),
|
||||
@@ -623,7 +1232,7 @@ export const api = {
|
||||
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 }),
|
||||
shortInterest: (symbol: string) =>
|
||||
trpcQuery<{ symbol: string; sharesShort: number | null; sharesShortPriorMonth: number | null; sharesPercentSharesOut: number | null; shortRatio: number | null; shortPercentOfFloat: number | null; dateShortInterest: string | null; floatShares: number | null; sharesOutstanding: number | null; daysToCover: number | null; settlementDate: string | null; history: Array<{ settlementDate: string; sharesShort: number | null; avgDailyVolume: number | null; daysToCover: number }> | null; discrepancyPct: number | null; discrepancyBetween: string | null; sources: { yfinance: { available: boolean; stale: boolean }; nasdaq: { available: boolean; stale: boolean }; finra: { available: boolean; stale: boolean } }; stale: boolean }>("institutional.shortInterest", { symbol }),
|
||||
trpcQuery<{ symbol: string; sharesShort: number | null; sharesShortPriorMonth: number | null; sharesPercentSharesOut: number | null; shortRatio: number | null; shortPercentOfFloat: number | null; dateShortInterest: string | null; floatShares: number | null; sharesOutstanding: number | null; daysToCover: number | null; settlementDate: string | null; history: Array<{ settlementDate: string; sharesShort: number | null; avgDailyVolume: number | null; daysToCover: number }> | null; finraBiweeklyHistory: Array<{ settlementDate: string; currentShortPosition: number; previousShortPosition: number | null; daysToCover: number | null; changePercent: number | null }>; finraWeekly: Array<{ weekEnding: string; shortVolume: number; totalVolume: number; shortPct: number }>; discrepancyPct: number | null; discrepancyBetween: string | null; sources: { yfinance: { available: boolean; stale: boolean }; nasdaq: { available: boolean; stale: boolean }; finra: { available: boolean; stale: boolean } }; stale: boolean }>("institutional.shortInterest", { symbol }),
|
||||
},
|
||||
emotionLogger: {
|
||||
add: (input: { tradeExecutionId: string; priceAtEvent?: number; emotion: string; note?: string }) =>
|
||||
@@ -646,6 +1255,9 @@ export const api = {
|
||||
trpcMutate<AlertSubscriptionRow>("alerts.updateSubscription", { id, ...updates }),
|
||||
deleteSubscription: (id: string) =>
|
||||
trpcMutate<{ ok: boolean }>("alerts.deleteSubscription", { id }),
|
||||
listTypes: () => trpcQuery<AlertTypeToggleRow[]>("alerts.listTypes"),
|
||||
toggleType: (alertType: string, enabled: boolean) =>
|
||||
trpcMutate<AlertTypeToggleRow[]>("alerts.toggleType", { alertType, enabled }),
|
||||
},
|
||||
x: {
|
||||
cashtag_search: (symbol: string, limit = 30) => trpcQuery<{ posts: XPost[]; configured: boolean }>("x.cashtag_search", { symbol, limit }),
|
||||
@@ -661,8 +1273,27 @@ export const api = {
|
||||
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"),
|
||||
alertStatus: () => trpcQuery<AlertStatusRow>("admin.alertStatus"),
|
||||
resetBackoff: (sourceKind: string) => trpcMutate<{ cleared: number }>("admin.resetBackoff", { sourceKind }),
|
||||
queueSecFetch: (symbol: string) => trpcMutate<{ ok: boolean }>("admin.queueSecFetch", { symbol }),
|
||||
dealerMapIntegrity: (input: { symbol: string; asOf?: string; recompute?: boolean }) =>
|
||||
trpcMutate<{
|
||||
mode: "live" | "replay";
|
||||
symbol: string;
|
||||
ok: boolean;
|
||||
summary: string;
|
||||
integrityText: string;
|
||||
integrity: DealerIntegrityReport;
|
||||
king: { strike: number; expiry: string; gex: number } | null;
|
||||
spot: number | null;
|
||||
asOf?: string;
|
||||
spotSource?: string;
|
||||
surfaceRowCount?: number;
|
||||
keptPriorMap?: boolean;
|
||||
cells?: number;
|
||||
quality?: unknown;
|
||||
snapshotCompare?: unknown;
|
||||
}>("admin.dealerMapIntegrity", input),
|
||||
queueStatus: () => trpcQuery<QueueStatus>("admin.queueStatus"),
|
||||
queuePause: () => trpcMutate<{ paused: boolean }>("admin.queuePause", undefined),
|
||||
queueResume: () => trpcMutate<{ paused: boolean }>("admin.queueResume", undefined),
|
||||
@@ -678,7 +1309,7 @@ export const api = {
|
||||
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),
|
||||
trpcMutate<{ total: number; enqueued: number }>("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"),
|
||||
@@ -699,8 +1330,143 @@ export const api = {
|
||||
trpcMutate<{ ok: boolean }>("admin.smtpConfigUpdate", input),
|
||||
smtpConfigTest: () => trpcMutate<{ ok: boolean }>("admin.smtpConfigTest", {}),
|
||||
},
|
||||
strategies: {
|
||||
list: () => trpcQuery<Array<{ id: string; name: string; components: string; unlocked: boolean; created_at: string }>>("strategies.list"),
|
||||
create: (input: { name: string; components?: unknown[] }) => trpcMutate<{ id: string }>("strategies.create", input),
|
||||
get: (id: string) => trpcQuery<Record<string, unknown>>("strategies.get", { id }),
|
||||
listPresets: () => trpcQuery<StrategyPreset[]>("strategies.listPresets"),
|
||||
getPreset: (id: string) => trpcQuery<StrategyPreset>("strategies.getPreset", { id }),
|
||||
forkPreset: (presetId: string) => trpcMutate<{ id: string; name: string }>("strategies.forkPreset", { presetId }),
|
||||
suggestTickers: (presetId: string) => trpcQuery<TickerSuggestion[]>("strategies.suggestTickers", { presetId }),
|
||||
},
|
||||
backtest: {
|
||||
run: (input: { strategyId: string; symbol: string; timeframe?: string }) =>
|
||||
trpcQuery<{ equityCurve: Array<{ ts: string; equity: number }>; exitReasons: string[]; sampleSize: number }>("backtest.run", input),
|
||||
evaluateLatest: (input: { strategyId: string; symbol: string }) =>
|
||||
trpcQuery<{ fires: boolean; conditions: Record<string, boolean | null> }>("backtest.evaluateLatest", input),
|
||||
runPortfolio: (input: { allocations: Array<{ symbol: string; targetPct: number }>; rebalanceFreq?: string; driftThreshold?: number }) =>
|
||||
trpcQuery<PortfolioBacktestResult>("backtest.runPortfolio", input),
|
||||
},
|
||||
screener: {
|
||||
filter: (input: { expression: string; scope?: "watchlist" | "sector"; sector?: string }) =>
|
||||
trpcQuery<{ results: Array<{ symbol: string; matched: boolean; why?: string[] } | string>; scope: string }>("screener.filter", input),
|
||||
strategy: (input: { strategyId: string }) =>
|
||||
trpcQuery<{ results: Array<{ symbol: string; matched: boolean; why?: string[] } | string> }>("screener.strategy", input),
|
||||
},
|
||||
reports: {
|
||||
generate: (input: {
|
||||
scope: "symbol" | "watchlist" | "portfolio" | "rotation" | "sizing_year" | "risk_posture";
|
||||
symbol?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}) => trpcQuery<{ html?: string; markdown?: string; title?: string; [k: string]: unknown }>("reports.generate", input),
|
||||
},
|
||||
derisking: {
|
||||
suggest: (input: { symbol: string; currentPrice: number; avgCost: number; shares: number; ema21?: number; ema50?: number; thesisStatus?: string; currentRegime?: string; portfolioCorrelation?: number; profitTargets?: number[]; dividendYield?: number; payoutRatio?: number }) =>
|
||||
trpcQuery<{ suggestions: Array<{ strategy: { id: string; name: string; description: string; alfredFraming: string; requiresOptionsSleeve: boolean }; applicable: boolean; reason: string; details: string }> }>("derisking.suggest", input),
|
||||
dividendHealth: (symbol: string) =>
|
||||
trpcQuery<{ data: Record<string, unknown> | null }>("derisking.dividendHealth", { symbol }),
|
||||
},
|
||||
thesisMonitor: {
|
||||
assess: (input: { thesis: string; events: Array<{ type: string; date: string; detail?: string }> }) => trpcQuery<{ status: string; confidence: number; reasoning: string[] }>("thesisMonitor.assess", input),
|
||||
},
|
||||
trades: {
|
||||
list: () => trpcQuery<Array<Record<string, unknown>>>("trades.list"),
|
||||
get: (id: string) => trpcQuery<Record<string, unknown>>("trades.get", { id }),
|
||||
save: (input: {
|
||||
id: string;
|
||||
symbol: string;
|
||||
date: string;
|
||||
status: string;
|
||||
tier: string;
|
||||
environment?: string;
|
||||
strategyId?: string;
|
||||
confluenceScore?: number;
|
||||
riskPct?: number;
|
||||
positionSize?: number;
|
||||
entryPrice?: number;
|
||||
stopLoss?: number;
|
||||
targets?: string;
|
||||
justifications?: string;
|
||||
checklistCompleted?: number;
|
||||
allocationPlanId?: string;
|
||||
allocationRole?: string;
|
||||
tradeId?: string;
|
||||
}) => trpcMutate<{ ok: boolean; id: string }>("trades.save", input),
|
||||
close: (id: string, realizedPnl: number, reflection?: string) =>
|
||||
trpcMutate<{ ok: boolean }>("trades.close", { id, realizedPnl, ...(reflection ? { reflection } : {}) }),
|
||||
stats: () => trpcQuery<{ totalClosed: number; profitable: number }>("trades.stats"),
|
||||
},
|
||||
theses: {
|
||||
list: () => trpcQuery<Array<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
symbol: string;
|
||||
statement: string;
|
||||
invalidation_criteria: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>>("theses.list"),
|
||||
get: (id: string) => trpcQuery<Record<string, unknown>>("theses.get", { id }),
|
||||
create: (input: {
|
||||
symbol: string;
|
||||
statement: string;
|
||||
invalidationCriteria?: string[];
|
||||
status?: "intact" | "weakening" | "broken";
|
||||
}) => trpcMutate<{ id: string; symbol: string }>("theses.create", input),
|
||||
update: (input: {
|
||||
id: string;
|
||||
statement?: string;
|
||||
invalidationCriteria?: string[];
|
||||
status?: "intact" | "weakening" | "broken";
|
||||
}) => trpcMutate<{ ok: boolean }>("theses.update", input),
|
||||
delete: (id: string) => trpcMutate<{ ok: boolean }>("theses.delete", { id }),
|
||||
},
|
||||
macro: {
|
||||
commentary: () => trpcQuery<{ shortTerm: string; longTerm: string; disclaimer: string }>("macro.commentary"),
|
||||
regimeClassify: () => trpcQuery<{ regime: string; confidence?: number; factors?: unknown }>("macro.regimeClassify"),
|
||||
},
|
||||
};
|
||||
|
||||
export interface TickerSuggestion {
|
||||
symbol: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
allocationPct: number;
|
||||
reason: string;
|
||||
source: 'live' | 'default';
|
||||
}
|
||||
|
||||
export interface StrategyPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
complexity: string;
|
||||
scope: string;
|
||||
description: string;
|
||||
entry_rules: string;
|
||||
exit_profile: string;
|
||||
risk_policy: string;
|
||||
instrument_type: string;
|
||||
portfolio_template: string | null;
|
||||
conviction_tier_default: string | null;
|
||||
requires_options_unlock: number;
|
||||
forkable: number;
|
||||
example_tickers: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PortfolioBacktestResult {
|
||||
equityCurve: Array<{ date: string; value: number }>;
|
||||
totalReturn: number;
|
||||
cagr: number;
|
||||
maxDrawdown: number;
|
||||
positions: Array<{ symbol: string; targetPct: number; totalReturn: number; finalValue: number }>;
|
||||
rebalanceEvents: Array<{ date: string; type: string }>;
|
||||
correlationMatrix: Record<string, Record<string, number>>;
|
||||
sampleSize: number;
|
||||
}
|
||||
|
||||
export interface LintResult {
|
||||
symbol: string;
|
||||
kind: string;
|
||||
@@ -775,25 +1541,6 @@ export interface AddOptionLegInput {
|
||||
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;
|
||||
@@ -831,8 +1578,15 @@ export interface RiskPostureResult {
|
||||
recommendedActions: RiskRecommendation[];
|
||||
halt: { haltedUntil: string; triggeredBy: string; ts: string } | null;
|
||||
holdingsCount: number;
|
||||
optionLegsCount?: number;
|
||||
totalExposureUsd: number;
|
||||
options?: OptionRiskSummary;
|
||||
optionRisk?: {
|
||||
premiumAtRiskUsd: number;
|
||||
cashReservedUsd: number;
|
||||
creditReceivedUsd: number;
|
||||
capitalCommittedUsd: number;
|
||||
uncoveredShortCallCount: number;
|
||||
};
|
||||
footer: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ export function useFeatureAccess(): FeatureAccess {
|
||||
fetchUser();
|
||||
}, [fetchUser]);
|
||||
|
||||
// Settings / interview can update density without a full reload
|
||||
useEffect(() => {
|
||||
const onProfile = () => fetchUser();
|
||||
window.addEventListener("workspace-profile-changed", onProfile);
|
||||
return () => window.removeEventListener("workspace-profile-changed", onProfile);
|
||||
}, [fetchUser]);
|
||||
|
||||
const modules: ModuleKey[] = user?.modules
|
||||
? (user.modules.filter((m) => ALL_MODULES.includes(m as ModuleKey)) as ModuleKey[])
|
||||
: DEFAULT_MODULES;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Run `tick` on an interval, but pause while the document is hidden
|
||||
* (background Safari tab / phone locked). Resumes with an immediate tick
|
||||
* when the page becomes visible again.
|
||||
*/
|
||||
export function useVisibilityAwarePoll(
|
||||
tick: () => void | Promise<void>,
|
||||
intervalMs: number,
|
||||
enabled = true,
|
||||
): void {
|
||||
const tickRef = useRef(tick);
|
||||
tickRef.current = tick;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || intervalMs <= 0) return;
|
||||
|
||||
let id: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const run = () => {
|
||||
void tickRef.current();
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (id != null) return;
|
||||
id = setInterval(run, intervalMs);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (id != null) {
|
||||
clearInterval(id);
|
||||
id = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
stop();
|
||||
} else {
|
||||
run();
|
||||
start();
|
||||
}
|
||||
};
|
||||
|
||||
if (!document.hidden) start();
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [intervalMs, enabled]);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
import { useMemo } from "react";
|
||||
import { useFeatureAccess } from "@/lib/useFeatureAccess";
|
||||
import {
|
||||
parseDensity,
|
||||
overviewSections,
|
||||
isNavVisible,
|
||||
type Density,
|
||||
type ExperienceStage,
|
||||
type Goal,
|
||||
type Horizon,
|
||||
type JargonComfort,
|
||||
type NavItemId,
|
||||
} from "@/lib/workspace-profile";
|
||||
|
||||
export interface WorkspaceProfile {
|
||||
loading: boolean;
|
||||
density: Density;
|
||||
experienceStage: ExperienceStage | null;
|
||||
goal: Goal | null;
|
||||
horizon: Horizon | null;
|
||||
jargonComfort: JargonComfort;
|
||||
complexity: string | null;
|
||||
onboarded: boolean;
|
||||
isNavVisible: (id: NavItemId) => boolean;
|
||||
overview: ReturnType<typeof overviewSections>;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useWorkspaceProfile(): WorkspaceProfile {
|
||||
const { user, loading, refresh } = useFeatureAccess();
|
||||
|
||||
return useMemo(() => {
|
||||
const density = parseDensity(user?.density, user?.complexity);
|
||||
const experienceStage = (user?.experienceStage as ExperienceStage | undefined) ?? null;
|
||||
const goal = (user?.goal as Goal | undefined) ?? null;
|
||||
const horizon = (user?.horizon as Horizon | undefined) ?? null;
|
||||
const jargonComfort = (user?.jargonComfort as JargonComfort | undefined) ?? "plain";
|
||||
|
||||
return {
|
||||
loading,
|
||||
density,
|
||||
experienceStage,
|
||||
goal,
|
||||
horizon,
|
||||
jargonComfort,
|
||||
complexity: user?.complexity ?? null,
|
||||
onboarded: !!user?.onboarded,
|
||||
isNavVisible: (id: NavItemId) => isNavVisible(id, density),
|
||||
overview: overviewSections(density),
|
||||
refresh,
|
||||
};
|
||||
}, [user, loading, refresh]);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Equity volume-by-price profile from OHLC bars (not options OI).
|
||||
* Uniform volume split across bins that a bar's low–high spans.
|
||||
*/
|
||||
|
||||
export interface VolumeProfileCandle {
|
||||
h: number;
|
||||
l: number;
|
||||
c?: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
export interface VolumeProfileBin {
|
||||
priceLo: number;
|
||||
priceHi: number;
|
||||
mid: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface VolumeProfileResult {
|
||||
bins: VolumeProfileBin[];
|
||||
/** Mid-price of the highest-volume bin. */
|
||||
pocMid: number | null;
|
||||
/** Value-area high / low (mids of outer bins in the value area). */
|
||||
vah: number | null;
|
||||
val: number | null;
|
||||
totalVolume: number;
|
||||
}
|
||||
|
||||
export interface BuildVolumeProfileOpts {
|
||||
/** Number of price bins. Default 32. */
|
||||
bins?: number;
|
||||
/** Fraction of volume for value area (e.g. 0.7). Default 0.7. */
|
||||
valueAreaPct?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a volume profile from candles in the visible range.
|
||||
* Empty or zero-volume inputs return empty bins.
|
||||
*/
|
||||
export function buildVolumeProfile(
|
||||
candles: VolumeProfileCandle[],
|
||||
opts: BuildVolumeProfileOpts = {},
|
||||
): VolumeProfileResult {
|
||||
const binCount = Math.max(2, Math.min(80, Math.floor(opts.bins ?? 32)));
|
||||
const valueAreaPct = Math.min(0.99, Math.max(0.5, opts.valueAreaPct ?? 0.7));
|
||||
|
||||
const usable = candles.filter(
|
||||
(c) =>
|
||||
Number.isFinite(c.h) &&
|
||||
Number.isFinite(c.l) &&
|
||||
Number.isFinite(c.v) &&
|
||||
c.v > 0 &&
|
||||
c.h >= c.l,
|
||||
);
|
||||
|
||||
if (usable.length === 0) {
|
||||
return { bins: [], pocMid: null, vah: null, val: null, totalVolume: 0 };
|
||||
}
|
||||
|
||||
let priceMin = Infinity;
|
||||
let priceMax = -Infinity;
|
||||
let totalVolume = 0;
|
||||
for (const c of usable) {
|
||||
if (c.l < priceMin) priceMin = c.l;
|
||||
if (c.h > priceMax) priceMax = c.h;
|
||||
totalVolume += c.v;
|
||||
}
|
||||
|
||||
// Flat range: single effective level
|
||||
if (!(priceMax > priceMin)) {
|
||||
const mid = priceMin;
|
||||
const bins: VolumeProfileBin[] = [
|
||||
{ priceLo: mid, priceHi: mid, mid, volume: totalVolume },
|
||||
];
|
||||
return { bins, pocMid: mid, vah: mid, val: mid, totalVolume };
|
||||
}
|
||||
|
||||
const width = (priceMax - priceMin) / binCount;
|
||||
const volumes = new Array<number>(binCount).fill(0);
|
||||
|
||||
for (const c of usable) {
|
||||
const lo = c.l;
|
||||
const hi = c.h;
|
||||
// Inclusive bins from low to high
|
||||
let i0 = Math.floor((lo - priceMin) / width);
|
||||
let i1 = Math.floor((hi - priceMin) / width);
|
||||
if (i0 < 0) i0 = 0;
|
||||
if (i1 >= binCount) i1 = binCount - 1;
|
||||
if (i1 < i0) i1 = i0;
|
||||
const n = i1 - i0 + 1;
|
||||
const share = c.v / n;
|
||||
for (let i = i0; i <= i1; i++) volumes[i] += share;
|
||||
}
|
||||
|
||||
const bins: VolumeProfileBin[] = volumes.map((volume, i) => {
|
||||
const priceLo = priceMin + i * width;
|
||||
const priceHi = i === binCount - 1 ? priceMax : priceMin + (i + 1) * width;
|
||||
return {
|
||||
priceLo,
|
||||
priceHi,
|
||||
mid: (priceLo + priceHi) / 2,
|
||||
volume,
|
||||
};
|
||||
});
|
||||
|
||||
// POC = max volume bin
|
||||
let pocIdx = 0;
|
||||
for (let i = 1; i < bins.length; i++) {
|
||||
if (bins[i].volume > bins[pocIdx].volume) pocIdx = i;
|
||||
}
|
||||
|
||||
// Value area: expand from POC until we cover valueAreaPct of total volume
|
||||
const target = totalVolume * valueAreaPct;
|
||||
let lo = pocIdx;
|
||||
let hi = pocIdx;
|
||||
let covered = bins[pocIdx].volume;
|
||||
while (covered < target && (lo > 0 || hi < bins.length - 1)) {
|
||||
const nextLo = lo > 0 ? bins[lo - 1].volume : -1;
|
||||
const nextHi = hi < bins.length - 1 ? bins[hi + 1].volume : -1;
|
||||
if (nextHi >= nextLo) {
|
||||
hi++;
|
||||
covered += bins[hi].volume;
|
||||
} else {
|
||||
lo--;
|
||||
covered += bins[lo].volume;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
bins,
|
||||
pocMid: bins[pocIdx].mid,
|
||||
vah: bins[hi].priceHi,
|
||||
val: bins[lo].priceLo,
|
||||
totalVolume,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Workspace density policy for the investment management workbench.
|
||||
* Adapts nav and surface defaults by experience - not a course curriculum.
|
||||
*/
|
||||
|
||||
export type Density = "focused" | "standard" | "full";
|
||||
export type ExperienceStage = "never_invested" | "some_stocks" | "active_self_directed";
|
||||
export type Goal = "grow" | "income" | "protect";
|
||||
export type Horizon = "short" | "medium" | "long";
|
||||
export type JargonComfort = "plain" | "mixed" | "technical";
|
||||
|
||||
/** Minimum density required to show a nav/route item in the primary chrome. */
|
||||
export type DensityGate = Density;
|
||||
|
||||
const DENSITY_RANK: Record<Density, number> = {
|
||||
focused: 0,
|
||||
standard: 1,
|
||||
full: 2,
|
||||
};
|
||||
|
||||
export function densityAtLeast(current: Density, required: DensityGate): boolean {
|
||||
return DENSITY_RANK[current] >= DENSITY_RANK[required];
|
||||
}
|
||||
|
||||
export function parseDensity(raw: string | null | undefined, complexity?: string | null): Density {
|
||||
if (raw === "focused" || raw === "standard" || raw === "full") return raw;
|
||||
if (complexity === "advanced") return "full";
|
||||
if (complexity === "intermediate") return "standard";
|
||||
return "focused";
|
||||
}
|
||||
|
||||
/** Nav item ids used for density filtering (shared desktop + mobile). */
|
||||
export type NavItemId =
|
||||
| "portfolio"
|
||||
| "monitor"
|
||||
| "risk"
|
||||
| "research"
|
||||
| "market"
|
||||
| "charts"
|
||||
| "institutional"
|
||||
| "strategies"
|
||||
| "guided-start"
|
||||
| "plan"
|
||||
| "theses"
|
||||
| "journal"
|
||||
| "exits"
|
||||
| "goals"
|
||||
| "screener"
|
||||
| "lab"
|
||||
| "reports"
|
||||
| "filings"
|
||||
| "options"
|
||||
| "dealer-flow"
|
||||
| "alerts"
|
||||
| "settings"
|
||||
| "admin"
|
||||
| "funds";
|
||||
|
||||
/** Minimum density for each primary destination. */
|
||||
export const NAV_DENSITY: Record<NavItemId, DensityGate> = {
|
||||
portfolio: "focused",
|
||||
/** @deprecated merged into portfolio; kept so old links still density-check */
|
||||
monitor: "focused",
|
||||
risk: "focused",
|
||||
research: "focused",
|
||||
market: "focused",
|
||||
charts: "focused",
|
||||
institutional: "standard",
|
||||
strategies: "standard",
|
||||
"guided-start": "focused",
|
||||
plan: "focused",
|
||||
theses: "focused",
|
||||
journal: "focused",
|
||||
exits: "standard",
|
||||
goals: "full",
|
||||
screener: "standard",
|
||||
lab: "standard",
|
||||
reports: "standard",
|
||||
filings: "full",
|
||||
options: "full",
|
||||
"dealer-flow": "focused",
|
||||
alerts: "focused",
|
||||
settings: "focused",
|
||||
admin: "focused",
|
||||
funds: "focused",
|
||||
};
|
||||
|
||||
export function isNavVisible(id: NavItemId, density: Density): boolean {
|
||||
return densityAtLeast(density, NAV_DENSITY[id]);
|
||||
}
|
||||
|
||||
/** Overview / research page section visibility. */
|
||||
export function overviewSections(density: Density): {
|
||||
showAnalystRatings: boolean;
|
||||
showShortInterest: boolean;
|
||||
showTickerContext: boolean;
|
||||
showCompany: boolean;
|
||||
showSocial: boolean;
|
||||
showFilings: boolean;
|
||||
showOptions: boolean;
|
||||
} {
|
||||
if (density === "focused") {
|
||||
return {
|
||||
showAnalystRatings: false,
|
||||
showShortInterest: false,
|
||||
showTickerContext: true,
|
||||
showCompany: true,
|
||||
showSocial: false,
|
||||
showFilings: false,
|
||||
showOptions: false,
|
||||
};
|
||||
}
|
||||
if (density === "standard") {
|
||||
return {
|
||||
showAnalystRatings: true,
|
||||
showShortInterest: false,
|
||||
showTickerContext: true,
|
||||
showCompany: true,
|
||||
showSocial: false,
|
||||
showFilings: true,
|
||||
showOptions: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
showAnalystRatings: true,
|
||||
showShortInterest: true,
|
||||
showTickerContext: true,
|
||||
showCompany: true,
|
||||
showSocial: true,
|
||||
showFilings: true,
|
||||
showOptions: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const EXPERIENCE_LABELS: Record<ExperienceStage, string> = {
|
||||
never_invested: "New to investing",
|
||||
some_stocks: "Some stock or fund experience",
|
||||
active_self_directed: "Active self-directed investor",
|
||||
};
|
||||
|
||||
export const DENSITY_LABELS: Record<Density, string> = {
|
||||
focused: "Focused - core management surfaces",
|
||||
standard: "Standard - process + more research",
|
||||
full: "Full - all tools",
|
||||
};
|
||||
|
||||
export const GOAL_LABELS: Record<Goal, string> = {
|
||||
grow: "Grow capital over time",
|
||||
income: "Generate income",
|
||||
protect: "Protect capital, with some growth",
|
||||
};
|
||||
|
||||
export const HORIZON_LABELS: Record<Horizon, string> = {
|
||||
short: "Under 3 years",
|
||||
medium: "3 to 10 years",
|
||||
long: "More than 10 years",
|
||||
};
|
||||
Reference in New Issue
Block a user