feat: Unraid deploy, dealer-flow heatmap, confluence zones, 13F capture
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:
@@ -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}`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user