feat: Unraid deploy, dealer-flow heatmap, confluence zones, 13F capture
CI / Test (push) Canceled after 0s
CI / Build and push (push) Canceled after 0s

Ship Node production images, Unraid compose, and Gitea CI/CD (test then
push registry images; cron script if no runner). Rebuild dealer flow as a
heatmap-first map with integrity gates and chart helpers. Add confluence
zone rules, session clock, capture evidence, and tighter 13F/queue/options
paths, plus the matching UI and tests.
This commit is contained in:
Investor Flow Build
2026-08-18 14:10:02 -04:00
parent f7efea2178
commit 90e1829d39
113 changed files with 7606 additions and 1947 deletions
+206
View File
@@ -0,0 +1,206 @@
/**
* First-paint chrome for Dealer Flow: levels line, trust pill, as-of clock.
* Pure helpers so the page can stay quiet by default.
*/
import type {
DealerIntegrityReport,
DealerMapGetResult,
DealerMapPayload,
} from "./trpc";
export type MetricMode = "gex" | "vex" | "oi";
/** The only first-paint choice: GEX vs VEX. */
export type MapMetric = "gex" | "vex";
export type ProfileMode = "off" | "oi" | "vol";
export type GexConvention = "classic" | "dealer";
export type DrawerId = "reading" | "method" | "study";
export function normalizeMetric(raw: string | null | undefined): MapMetric {
return raw === "vex" ? "vex" : "gex";
}
export function formatUsdCompact(n: number): string {
const abs = Math.abs(n);
const sign = n < 0 ? "−" : "+";
if (abs >= 1e9) return `${sign}$${(abs / 1e9).toFixed(2)}B`;
if (abs >= 1e6) return `${sign}$${(abs / 1e6).toFixed(2)}M`;
if (abs >= 1e3) return `${sign}$${(abs / 1e3).toFixed(1)}K`;
return `${sign}$${abs.toFixed(0)}`;
}
export function formatCountCompact(n: number): string {
const abs = Math.abs(n);
if (abs >= 1e6) return `${(abs / 1e6).toFixed(1)}M`;
if (abs >= 1e3) return `${(abs / 1e3).toFixed(1)}K`;
return String(Math.round(abs));
}
export function metricValue(
cell: Pick<DealerMapPayload["cells"][number], "gex" | "vex" | "oi">,
mode: MetricMode,
): number {
if (mode === "vex") return cell.vex;
if (mode === "oi") return cell.oi;
return cell.gex;
}
export function metricMaxAbs(
cells: Array<Pick<DealerMapPayload["cells"][number], "gex" | "vex" | "oi">>,
mode: MetricMode,
): number {
let m = 0;
for (const c of cells) m = Math.max(m, Math.abs(metricValue(c, mode)));
return m;
}
export function formatMetricValue(n: number, mode: MetricMode): string {
if (mode === "oi") return formatCountCompact(n);
return formatUsdCompact(n).replace("$", "");
}
export function metricLabel(mode: MetricMode): string {
if (mode === "vex") return "VEX";
if (mode === "oi") return "OI";
return "GEX";
}
export function formatAsOfEt(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
timeZone: "America/New_York",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
}
export function providerLabel(providerId: string | undefined | null): string {
if (providerId === "yfinance") return "Yahoo snapshot";
return providerId?.trim() ? providerId : "Snapshot";
}
export type LevelPart = { key: string; label: string; value: string };
export function levelsLine(
map: Pick<DealerMapPayload, "levels" | "regimeAtSpot">,
): LevelPart[] {
const parts: LevelPart[] = [];
if (map.levels.king) {
parts.push({ key: "king", label: "King", value: String(map.levels.king.strike) });
}
if (map.levels.flip != null) {
parts.push({ key: "flip", label: "Flip", value: String(map.levels.flip) });
}
if (map.levels.callWall != null) {
parts.push({ key: "call", label: "Call", value: String(map.levels.callWall) });
}
if (map.levels.putWall != null) {
parts.push({ key: "put", label: "Put", value: String(map.levels.putWall) });
}
const regime =
map.regimeAtSpot === "positive"
? "+GEX near spot"
: map.regimeAtSpot === "negative"
? "−GEX near spot"
: "Mixed near spot";
parts.push({ key: "regime", label: "", value: regime });
return parts;
}
export type TrustTone = "degraded" | "incomplete" | "pending";
export type TrustStatus = {
tone: TrustTone;
label: string;
details: string[];
};
/** Hide when the book is complete and greeks are ok. Surface only what changes trust. */
export function trustStatus(args: {
integrity?: DealerIntegrityReport;
status?: DealerMapGetResult["status"];
quality?: DealerMapPayload["quality"];
message?: string;
}): TrustStatus | null {
const details: string[] = [];
const hard = (args.integrity?.checks ?? [])
.filter((c) => c.level === "hard" && !c.ok)
.map((c) => c.detail);
const soft = (args.integrity?.checks ?? [])
.filter((c) => c.level === "soft" && !c.ok)
.map((c) => c.detail);
const greekNotes = args.quality?.notes ?? [];
const greek = args.quality?.greekQuality;
const incomplete =
args.integrity?.status === "incomplete" || args.status === "incomplete";
if (incomplete) {
details.push(...hard);
if (args.message) details.push(args.message);
return { tone: "incomplete", label: "Incomplete", details: details.slice(0, 6) };
}
if (args.status === "pending") {
if (args.message) details.push(args.message);
return { tone: "pending", label: "Refresh scheduled", details };
}
const degraded =
args.integrity?.status === "degraded" ||
greek === "degraded" ||
greek === "poor";
if (!degraded) return null;
details.push(...soft);
if (greek === "poor") details.push("Greeks look unreliable on this refresh.");
else if (greek === "degraded") details.push("Greeks degraded (local BSM fill).");
details.push(...greekNotes);
if (
typeof args.quality?.badIvSkipPct === "number" &&
args.quality.badIvSkipPct > 0
) {
details.push(
`Skipped ${args.quality.badIvSkipPct.toFixed(0)}% of OI for bad IV · BSM fill ${args.quality.bsmFillPct.toFixed(0)}%.`,
);
}
return { tone: "degraded", label: "Degraded", details: uniqueNonEmpty(details).slice(0, 6) };
}
function uniqueNonEmpty(xs: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const x of xs) {
const t = x.trim();
if (!t || seen.has(t)) continue;
seen.add(t);
out.push(t);
}
return out;
}
const SCENARIO_LABEL: Record<string, string> = {
pos_gex_pos_vex: "Calm",
neg_gex_neg_vex: "Jumpy",
gex_dominates: "Price levels lead",
vex_dominates: "Vol leads",
mixed: "Mixed",
insufficient_data: "Thin book",
};
const PATTERN_LABEL: Record<string, string> = {
whipsaw: "Whipsaw",
rainbow_road: "Rainbow road",
rug_setup: "Rug setup",
};
export function scenarioLine(
scenario: string,
patterns: Array<{ id: string; confidence?: number }>,
): string {
const scen = SCENARIO_LABEL[scenario] ?? scenario.replace(/_/g, " ");
if (patterns.length === 0) return scen;
const names = patterns.map((p) => PATTERN_LABEL[p.id] ?? p.id.replace(/_/g, " ")).join(", ");
return `${scen} · ${names}`;
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Steversteves EMA 9/21 range levels (published Pine, not ATR).
* Lookback stats on close vs both EMAs; levels posted on each 9/21 cross.
*/
export interface Ema921Candle {
o: number;
h: number;
l: number;
c: number;
}
export type Ema921Sentiment = "above" | "below" | "between";
export type Ema921Cross = "up" | "down";
export interface Ema921Bar {
ema9?: number;
ema21?: number;
sentiment: Ema921Sentiment | null;
cross: Ema921Cross | null;
}
export interface Ema921Levels {
up1: number | null;
up2: number | null;
down1: number | null;
down2: number | null;
upFromIndex: number | null;
downFromIndex: number | null;
}
export interface Ema921RangeResult {
bars: Ema921Bar[];
levels: Ema921Levels;
}
const DEFAULT_LOOKBACK = 500;
function emaClose(closes: number[], period: number): (number | undefined)[] {
const result: (number | undefined)[] = new Array(closes.length);
if (period <= 0 || closes.length < period) return result;
let sum = 0;
for (let i = 0; i < period; i++) sum += closes[i]!;
let prev = sum / period;
result[period - 1] = prev;
const k = 2 / (period + 1);
for (let i = period; i < closes.length; i++) {
prev = (closes[i]! - prev) * k + prev;
result[i] = prev;
}
return result;
}
function windowStats(
candles: Ema921Candle[],
ema9: (number | undefined)[],
ema21: (number | undefined)[],
end: number,
lookback: number,
): {
maxAbove: number | null;
minBelow: number | null;
crossUpAvg: number | null;
crossDownAvg: number | null;
latestCrossUpOpen: number | null;
} {
const start = Math.max(0, end - lookback);
let maxAbove = -Infinity;
let minBelow = Infinity;
let upSum = 0;
let upN = 0;
let downSum = 0;
let downN = 0;
let latestCrossUpOpen: number | null = null;
for (let i = start; i <= end; i++) {
const e9 = ema9[i];
const e21 = ema21[i];
const bar = candles[i];
if (!bar || e9 == null || e21 == null) continue;
if (bar.c >= e9 && bar.c >= e21) {
if (bar.c > maxAbove) maxAbove = bar.c;
}
if (bar.c <= e9 && bar.c <= e21) {
if (bar.c < minBelow) minBelow = bar.c;
}
if (i > 0) {
const p9 = ema9[i - 1];
const p21 = ema21[i - 1];
if (p9 != null && p21 != null) {
if (p9 <= p21 && e9 > e21) {
upSum += bar.c;
upN += 1;
latestCrossUpOpen = bar.o;
} else if (p21 <= p9 && e21 > e9) {
downSum += bar.c;
downN += 1;
}
}
}
}
return {
maxAbove: Number.isFinite(maxAbove) ? maxAbove : null,
minBelow: Number.isFinite(minBelow) ? minBelow : null,
crossUpAvg: upN > 0 ? upSum / upN : null,
crossDownAvg: downN > 0 ? downSum / downN : null,
latestCrossUpOpen,
};
}
export function computeEma921Range(
candles: Ema921Candle[],
lookback = DEFAULT_LOOKBACK,
): Ema921RangeResult {
const closes = candles.map((c) => c.c);
const ema9 = emaClose(closes, 9);
const ema21 = emaClose(closes, 21);
const bars: Ema921Bar[] = candles.map((bar, i) => {
const e9 = ema9[i];
const e21 = ema21[i];
let sentiment: Ema921Sentiment | null = null;
let cross: Ema921Cross | null = null;
if (e9 != null && e21 != null) {
if (bar.c >= e9 && bar.c >= e21) sentiment = "above";
else if (bar.c <= e9 && bar.c <= e21) sentiment = "below";
else sentiment = "between";
if (i > 0) {
const p9 = ema9[i - 1];
const p21 = ema21[i - 1];
if (p9 != null && p21 != null) {
if (p9 <= p21 && e9 > e21) cross = "up";
else if (p21 <= p9 && e21 > e9) cross = "down";
}
}
}
return { ema9: e9, ema21: e21, sentiment, cross };
});
const levels: Ema921Levels = {
up1: null,
up2: null,
down1: null,
down2: null,
upFromIndex: null,
downFromIndex: null,
};
for (let i = 0; i < bars.length; i++) {
const cross = bars[i]!.cross;
if (!cross) continue;
const s = windowStats(candles, ema9, ema21, i, lookback);
const op = s.latestCrossUpOpen;
if (op == null) continue;
if (cross === "up" && s.maxAbove != null && s.crossUpAvg != null) {
const span = s.maxAbove - s.crossUpAvg;
levels.up1 = op + span / 2;
levels.up2 = op + span;
levels.upFromIndex = i;
}
if (cross === "down" && s.minBelow != null && s.crossDownAvg != null) {
const span = s.crossDownAvg - s.minBelow;
levels.down1 = op - span / 2;
levels.down2 = op - span;
levels.downFromIndex = i;
}
}
return { bars, levels };
}
+37
View File
@@ -0,0 +1,37 @@
import { formatRelativeTime } from "./relativeTime.ts";
export type PullStatus = "pending" | "in_flight" | "done" | "backoff" | "failed" | "none";
export interface LastUpdatedProps {
label?: string;
at?: string | null;
status?: PullStatus;
error?: string | null;
}
function line(label: string | undefined, body: string): string {
return label ? `${label} ${body}` : body;
}
/** Amber in the header when the cached print is older than a live Yahoo mark should be. */
export function quoteStampTone(at: string | null | undefined, now = Date.now()): "ok" | "stale" {
if (!at) return "stale";
const t = Date.parse(at);
if (!Number.isFinite(t)) return "stale";
return now - t > 5 * 60_000 ? "stale" : "ok";
}
export function lastUpdatedText(props: LastUpdatedProps): string {
const { label, at, status, error } = props;
if (status === "in_flight") return line(label, "pulling now");
if (status === "pending" && !at) return line(label, "queued, not pulled yet");
if (status === "none" || (!at && (status == null || status === "pending"))) {
return line(label, "never pulled");
}
if (status === "backoff" || status === "failed") {
const when = at ? formatRelativeTime(at) : "recently";
const hint = error ? ` (${error.replace(/^XCookieAdapter:\s*/i, "").slice(0, 72)})` : "";
return line(label, `last try failed ${when}${hint}`);
}
return line(label, formatRelativeTime(at));
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Pixel-free geometry for the Chart Lab price pane (OHLC candles + EMA 9/21 ribbon).
* Layers map these layouts through Recharts axis scales.
*/
export interface OhlcBar {
o: number;
h: number;
l: number;
c: number;
}
export interface CandleLayout {
cx: number;
wickTop: number;
wickBottom: number;
bodyX: number;
bodyY: number;
bodyW: number;
bodyH: number;
up: boolean;
}
export interface RibbonSample {
x: number;
ema9: number;
ema21: number;
}
export interface RibbonRun {
samples: RibbonSample[];
/** True when EMA 9 is at or above EMA 21 for this run. */
above: boolean;
}
export function layoutCandle(
bar: OhlcBar,
cx: number,
yOf: (price: number) => number,
bodyWidth: number,
minBodyPx = 1,
): CandleLayout | null {
const { o, h, l, c } = bar;
if (![o, h, l, c, cx].every(Number.isFinite)) return null;
const yH = yOf(h);
const yL = yOf(l);
const yO = yOf(o);
const yC = yOf(c);
if (![yH, yL, yO, yC].every(Number.isFinite)) return null;
const bodyW = Math.max(1, bodyWidth);
const bodyTop = Math.min(yO, yC);
const bodyBot = Math.max(yO, yC);
return {
cx,
wickTop: Math.min(yH, yL),
wickBottom: Math.max(yH, yL),
bodyX: cx - bodyW / 2,
bodyY: bodyTop,
bodyW,
bodyH: Math.max(bodyBot - bodyTop, minBodyPx),
up: c >= o,
};
}
export function priceExtents(
bars: Array<{
h: number;
l: number;
ema9?: number;
ema21?: number;
ema50?: number;
ema200?: number;
}>,
include: {
ema9?: boolean;
ema21?: boolean;
ema50?: boolean;
ema200?: boolean;
/** Range lines and other annotations. Folded in before padding so labels are not clipped. */
extras?: Array<number | null | undefined>;
} = {},
padFrac = 0.08,
): { minP: number; maxP: number } {
let mn = Infinity;
let mx = -Infinity;
for (const b of bars) {
if (Number.isFinite(b.h)) mx = Math.max(mx, b.h);
if (Number.isFinite(b.l)) mn = Math.min(mn, b.l);
const extras: Array<[boolean | undefined, number | undefined]> = [
[include.ema9, b.ema9],
[include.ema21, b.ema21],
[include.ema50, b.ema50],
[include.ema200, b.ema200],
];
for (const [on, v] of extras) {
if (!on || v == null || !Number.isFinite(v)) continue;
mx = Math.max(mx, v);
mn = Math.min(mn, v);
}
}
for (const v of include.extras ?? []) {
if (v == null || !Number.isFinite(v)) continue;
mx = Math.max(mx, v);
mn = Math.min(mn, v);
}
if (!Number.isFinite(mn) || !Number.isFinite(mx)) return { minP: 0, maxP: 1 };
const pad = (mx - mn) * padFrac || Math.max(Math.abs(mx) * 0.01, 1);
return { minP: mn - pad, maxP: mx + pad };
}
export function emaRelation(ema9?: number, ema21?: number): "above" | "below" | "even" | null {
if (ema9 == null || ema21 == null || !Number.isFinite(ema9) || !Number.isFinite(ema21)) return null;
const eps = Math.max(Math.abs(ema21) * 1e-6, 1e-6);
if (Math.abs(ema9 - ema21) <= eps) return "even";
return ema9 > ema21 ? "above" : "below";
}
function interpolateCross(a: RibbonSample, b: RibbonSample): RibbonSample {
const denom = (b.ema9 - a.ema9) - (b.ema21 - a.ema21);
const t = Math.abs(denom) < 1e-12 ? 0.5 : (a.ema21 - a.ema9) / denom;
const tt = Math.min(1, Math.max(0, t));
const ema = a.ema9 + tt * (b.ema9 - a.ema9);
return {
x: a.x + tt * (b.x - a.x),
ema9: ema,
ema21: ema,
};
}
export function ribbonRuns(samples: RibbonSample[]): RibbonRun[] {
const valid = samples.filter(
(s) => Number.isFinite(s.x) && Number.isFinite(s.ema9) && Number.isFinite(s.ema21),
);
if (valid.length < 2) return [];
const runs: RibbonRun[] = [];
let current: RibbonRun = {
samples: [valid[0]!],
above: valid[0]!.ema9 >= valid[0]!.ema21,
};
for (let i = 1; i < valid.length; i++) {
const prev = valid[i - 1]!;
const s = valid[i]!;
const above = s.ema9 >= s.ema21;
if (above === current.above) {
current.samples.push(s);
continue;
}
const cross = interpolateCross(prev, s);
current.samples.push(cross);
if (current.samples.length >= 2) runs.push(current);
current = { samples: [cross, s], above };
}
if (current.samples.length >= 2) runs.push(current);
return runs;
}
export function ribbonPath(samples: RibbonSample[], yOf: (price: number) => number): string {
if (samples.length < 2) return "";
const top = samples.map((s) => {
const y = yOf(s.ema9);
return Number.isFinite(y) ? `${s.x},${y}` : null;
});
const bot = [...samples].reverse().map((s) => {
const y = yOf(s.ema21);
return Number.isFinite(y) ? `${s.x},${y}` : null;
});
if (top.some((p) => p == null) || bot.some((p) => p == null)) return "";
return `M${top[0]}L${top.slice(1).join("L")}L${bot.join("L")}Z`;
}
+38
View File
@@ -0,0 +1,38 @@
/** Compact relative age for last-updated stamps. */
export function remainingMsUntil(iso: string | null | undefined, now = Date.now()): number {
if (!iso) return 0;
const t = Date.parse(iso);
if (!Number.isFinite(t)) return 0;
return Math.max(0, t - now);
}
/** Live remaining window, e.g. "4m 32s" or "1h 12m". */
export function formatCountdown(remainingMs: number): string {
const ms = Math.max(0, remainingMs);
if (ms <= 0) return "0s";
const totalSec = Math.ceil(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
export function formatRelativeTime(iso: string | null | undefined, now = Date.now()): string {
if (!iso) return "never";
const t = Date.parse(iso);
if (!Number.isFinite(t)) return "unknown";
const sec = Math.round((now - t) / 1000);
const abs = Math.abs(sec);
const ago = sec >= 0;
const suffix = ago ? "ago" : "from now";
if (abs < 45) return ago ? "just now" : "in a moment";
if (abs < 90) return `1m ${suffix}`;
if (abs < 3600) return `${Math.round(abs / 60)}m ${suffix}`;
if (abs < 5400) return `1h ${suffix}`;
if (abs < 86_400) return `${Math.round(abs / 3600)}h ${suffix}`;
if (abs < 86_400 * 1.5) return `1d ${suffix}`;
return `${Math.round(abs / 86_400)}d ${suffix}`;
}
+35 -1
View File
@@ -62,7 +62,41 @@ export const UI_STRINGS = {
// Chart Lab (Slice 5c)
chartsTitle: "Charts — price, volume, and indicator overlays",
priceChartCaption:
"Closing-price line with EMA overlays — visual context, not a signal.",
"EMA 9 and 21 with the typical close range after the last 9/21 cross. Context, not a signal.",
emaRibbonCaption:
"Lime fill: close is at or above both averages. Red fill: close is at or below both.",
ema921CrossUp: "9 crossed above 21",
ema921CrossDown: "9 crossed below 21",
ema921Up1: "Up range 1",
ema921Up2: "Up range 2",
ema921Down1: "Down range 1",
ema921Down2: "Down range 2",
ema921RangeHint:
"Typical close distance after the last EMA 9 / 21 cross, from the last 500 bars. Range 1 is half that move; range 2 is the full move.",
fetchingMinuteBars: "Waiting for minute bars. First open queues one shared Yahoo job; it can sit behind other Yahoo work.",
tfHint1m:
"Each bar is one minute. EMA 9 / 21 cover about 9 and 21 minutes. Ranges speak to the next minutes of this session. Yahoo keeps about 7 days.",
tfHint5m:
"Each bar is five minutes. EMA 9 / 21 cover about 45 minutes and 1.8 hours. Ranges speak to the same session. Yahoo keeps about 60 days.",
tfHint1d:
"Each bar is one trading day. EMA 9 / 21 cover about two weeks and one month. Ranges speak to days or a few weeks.",
tfHint1wk:
"Each bar is one week. EMA 9 / 21 cover about two months and five months. Ranges speak to a swing or a quarter, not today.",
tfHint1mo:
"Each bar is one month. EMA 9 / 21 cover about three quarters and almost two years. Ranges speak to long-horizon context.",
rangeHint1D: "Last trading session of this timeframe.",
rangeHint2D: "Last two sessions.",
rangeHint5D: "Last five sessions (about one week of minutes).",
rangeHint1Mchip: "About one month of 5-minute bars.",
rangeHint3M: "About 63 daily bars.",
rangeHint6M: "About 126 daily bars.",
rangeHint1Y: "About 252 daily bars.",
rangeHint5Y: "About five years of daily bars.",
rangeHintALL: "All bars we have for this timeframe.",
ema9Above21: "EMA 9 is above EMA 21",
ema9Below21: "EMA 9 is below EMA 21",
ema9Even21: "EMA 9 and EMA 21 are even",
candlesLabel: "Candles",
volumeLabel: "Volume",
volumeCaption:
"Number of shares traded per day — relative size, not a directional cue.",
+96 -4
View File
@@ -58,7 +58,7 @@ export interface FundHoldersResult {
symbol: string;
tracked: Array<{
fundId: string; fundName: string; shares: number | null; valueUsd: number | null;
asOf: string; source: string; weightPct: number | null;
asOf: string; source: string; notes: string | null; weightPct: number | null;
}>;
institutionCount: number;
}
@@ -79,6 +79,22 @@ export interface XAccountRow {
handle: string;
label: string;
created_at: string;
pullStatus?: string | null;
pullAt?: string | null;
pullError?: string | null;
lastPostAt?: string | null;
}
export interface FundFreshness {
xHandle: string | null;
xPull: {
status: "pending" | "in_flight" | "done" | "backoff" | "failed" | "none";
lastAttempt: string | null;
error: string | null;
};
lastPostAt: string | null;
lastCaptureAt: string | null;
last13fAt: string | null;
}
// ─── Dealer Flow types ─────────────────────────────────────────────────────
@@ -119,6 +135,8 @@ export interface DealerMapPayload {
symbol: string;
spot: number;
asOf: string;
quoteAsOf?: string | null;
chainAsOf?: string | null;
delayNote: string;
delaySeconds: number | null;
oiFreshness: string;
@@ -659,6 +677,8 @@ export interface XFedFeed {
nextCursor: string | null;
configured: boolean;
health?: { healthy: 'healthy' | 'degraded'; lastError: string | null } | null;
lastPullAt?: string | null;
queuedHandles?: number;
}
export interface MacroCommentary {
shortTerm: string;
@@ -672,6 +692,7 @@ export interface Snapshot {
candles: PriceCandle[] | null;
sector: SymbolMeta | null;
stale: { quote: boolean; candles: boolean; sector: boolean };
observed?: { quote: string | null; candles: string | null; sector: string | null };
}
export interface AuthUser {
userId: string;
@@ -783,7 +804,7 @@ export interface IndicatorPeriods {
}
export interface IndicatorOptions {
periods?: IndicatorPeriods;
timeframe?: '1d' | '1wk' | '1mo';
timeframe?: '1m' | '5m' | '1d' | '1wk' | '1mo';
}
export interface IndicatorResult {
ema?: Record<number, (number | null)[]>;
@@ -848,7 +869,9 @@ export const api = {
/** 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 ?? []),
candles: async (symbol: string, timeframe = "1d") => trpcQuery<{ symbol: string; timeframe: string; candles: PriceCandle[]; isStale: boolean; fetchedAt: string | null }>("market.candles", { symbol, timeframe }).then((r) => r?.candles ?? []),
candlesWithMeta: (symbol: string, timeframe = "1d") =>
trpcQuery<{ symbol: string; timeframe: string; candles: PriceCandle[]; isStale: boolean; fetchedAt: string | null }>("market.candles", { symbol, timeframe }),
indicators: (symbol: string, opts: IndicatorOptions) =>
trpcQuery<IndicatorResult>("market.indicators", { symbol, ...opts }),
truckSales: () => trpcQuery<TruckSalesData>("market.truckSales"),
@@ -890,16 +913,23 @@ export const api = {
list: () => trpcQuery<Array<{
id: string; ci_key: string; fund_name: string; manager_name: string;
x_handle: string | null; paywall_status: string; enabled: boolean;
freshness: FundFreshness | null;
}>>("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;
freshness: FundFreshness | null;
} | 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;
status: "held" | "exited"; last_held_shares: number | null;
prior_shares: number | null; share_delta: number | null;
change: "add" | "cut" | "flat" | "new" | null;
x_only: boolean;
evidence_status?: "ok" | "deleted" | "unknown";
}>>("funds.liveBook", input),
adminCreate: (input: {
ci_key: string; fund_name: string; manager_name: string; x_handle?: string;
@@ -928,7 +958,7 @@ export const api = {
symbol: string;
tracked: Array<{
fundId: string; fundName: string; shares: number | null; valueUsd: number | null;
asOf: string; source: string; weightPct: number | null;
asOf: string; source: string; notes: string | null; weightPct: number | null;
}>;
institutionCount: number;
}>("symbols.holders", input),
@@ -1305,6 +1335,8 @@ export const api = {
queueLogs: (key: string, limit: number) => trpcQuery<ErrorLogEntry[]>("admin.queueLogs", { key, limit }),
queueRetryJob: (key: string) => trpcMutate<{ ok: boolean }>("admin.queueRetryJob", { key }),
queueRetrySource: (sourceKind: string) => trpcMutate<{ cleared: number }>("admin.queueRetrySource", { sourceKind }),
clearSourceCooldown: (sourceKind: string) =>
trpcMutate<{ ok: boolean; sourceKind: string }>("admin.clearSourceCooldown", { sourceKind }),
queueSourcePause: (source: string) => trpcMutate<{ ok: boolean; source: string }>("admin.queueSourcePause", { source }),
queueSourceResume: (source: string) => trpcMutate<{ ok: boolean; source: string }>("admin.queueSourceResume", { source }),
queueSourceStop: (source: string) => trpcMutate<{ ok: boolean; source: string; cleared: number }>("admin.queueSourceStop", { source }),
@@ -1485,8 +1517,64 @@ export const api = {
body: 'bull' | 'bear' | 'exit';
firedAt: string;
qualityAtFire: string;
verdict: 'real' | 'false_alarm' | null;
priceConfirmed: boolean;
}>;
}>("confluence.signalHistory", { symbol }),
recentZones: (symbol: string, opts?: { rackId?: string; entries?: number; exits?: number }) =>
trpcQuery<{
symbol: string;
rackId: string | null;
entries: Array<{
kind: 'entry' | 'exit';
startAsOf: string;
endAsOf: string;
open: boolean;
qualityAtStart: string;
netEvidenceAtStart: number;
firedSlotIds: string[];
forwardReturn: number | null;
verdict: 'confirmed' | 'false_alarm' | 'open';
}>;
exits: Array<{
kind: 'entry' | 'exit';
startAsOf: string;
endAsOf: string;
open: boolean;
qualityAtStart: string;
netEvidenceAtStart: number;
firedSlotIds: string[];
forwardReturn: number | null;
verdict: 'confirmed' | 'false_alarm' | 'open';
}>;
entryRule: {
label: string;
source: string;
derivedAt: string | null;
sampleCaveat: string;
train: { hitRate: number | null; resolved: number; baselineHitRate: number | null } | null;
validate: { hitRate: number | null; resolved: number; baselineHitRate: number | null } | null;
};
exitRule: {
label: string;
source: string;
derivedAt: string | null;
sampleCaveat: string;
train: { hitRate: number | null; resolved: number; baselineHitRate: number | null } | null;
validate: { hitRate: number | null; resolved: number; baselineHitRate: number | null } | null;
};
coverage: { evaluatedDays: number; lookbackDays: number; replayComplete: boolean };
symbolScore: {
entry: { zones: number; resolved: number; confirmed: number; hitRate: number | null };
exit: { zones: number; resolved: number; confirmed: number; hitRate: number | null };
};
learningLedger: { ready: boolean; filled: number; total: number };
}>("confluence.recentZones", {
symbol,
...(opts?.rackId ? { rackId: opts.rackId } : {}),
...(opts?.entries ? { entries: opts.entries } : {}),
...(opts?.exits ? { exits: opts.exits } : {}),
}),
saveRack: (input: { id?: string; name: string; description?: string; slotIds: string[] }) =>
trpcMutate<{ id: string; name: string; slotIds: string[]; isSystem: boolean; description: string | null }>("confluence.saveRack", input),
corridorSnapshot: (symbol: string) =>
@@ -1734,6 +1822,10 @@ export interface QueueStatus {
pausedSources?: string[];
/** Operator-stopped sources (drain frozen, jobs cleared, enqueue blocked). */
stoppedSources?: string[];
pendingByKind?: Record<string, number>;
demandSize?: number;
dataPlaneHealthy?: boolean;
dataPlaneNotes?: string[];
}
export interface ScheduleEntry {