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
+154
View File
@@ -0,0 +1,154 @@
import { test } from "node:test";
import { strict as assert } from "node:assert";
import {
levelsLine,
normalizeMetric,
providerLabel,
scenarioLine,
trustStatus,
} from "../lib/dealerMapChrome.ts";
test("levelsLine lists king flip walls then regime", () => {
const parts = levelsLine({
regimeAtSpot: "positive",
levels: {
king: { strike: 41, expiry: "2026-08-21", gex: 1 },
gatekeepers: [],
flip: 38,
callWall: 50,
putWall: 30,
airPockets: [],
clusters: [],
},
});
assert.deepEqual(
parts.map((p) => `${p.label} ${p.value}`.trim()),
["King 41", "Flip 38", "Call 50", "Put 30", "+GEX near spot"],
);
});
test("trustStatus hides a complete book with ok greeks", () => {
assert.equal(
trustStatus({
status: "ok",
integrity: {
status: "complete",
checks: [],
hardFailed: [],
softFailed: [],
metrics: {
expiries: 5,
oiEligible: 10,
included: 10,
cells: 40,
byStrike: 20,
nearSpotOiStrikes: 8,
minOiStrikesPerExpiry: 4,
includePct: 100,
badIvSkipPct: 0,
bsmFillPct: 0,
},
},
quality: {
greekQuality: "ok",
oiEligible: 10,
included: 10,
skippedBadIv: 0,
bsmFills: 0,
fallbackIvUses: 0,
bsmFillPct: 0,
badIvSkipPct: 0,
fallbackIvPct: 0,
notes: [],
},
}),
null,
);
});
test("trustStatus surfaces incomplete hard fails", () => {
const s = trustStatus({
status: "incomplete",
integrity: {
status: "incomplete",
checks: [
{ id: "chain_age", level: "hard", ok: false, detail: "Chain is 3 sessions old." },
],
hardFailed: ["chain_age"],
softFailed: [],
metrics: {
expiries: 0,
oiEligible: 0,
included: 0,
cells: 0,
byStrike: 0,
nearSpotOiStrikes: 0,
minOiStrikesPerExpiry: 0,
includePct: 0,
badIvSkipPct: 0,
bsmFillPct: 0,
},
},
});
assert.equal(s?.tone, "incomplete");
assert.equal(s?.label, "Incomplete");
assert.ok(s?.details[0]?.includes("3 sessions"));
});
test("trustStatus surfaces degraded greeks without a complete chip", () => {
const s = trustStatus({
status: "ok",
integrity: {
status: "complete",
checks: [],
hardFailed: [],
softFailed: [],
metrics: {
expiries: 5,
oiEligible: 10,
included: 10,
cells: 40,
byStrike: 20,
nearSpotOiStrikes: 8,
minOiStrikesPerExpiry: 4,
includePct: 100,
badIvSkipPct: 0,
bsmFillPct: 100,
},
},
quality: {
greekQuality: "degraded",
oiEligible: 10,
included: 10,
skippedBadIv: 0,
bsmFills: 10,
fallbackIvUses: 0,
bsmFillPct: 100,
badIvSkipPct: 0,
fallbackIvPct: 0,
notes: ["Gamma is local BSM when the vendor omits it."],
},
});
assert.equal(s?.tone, "degraded");
assert.equal(s?.label, "Degraded");
assert.ok(s?.details.some((d) => /BSM/i.test(d)));
});
test("providerLabel maps yfinance to Yahoo snapshot", () => {
assert.equal(providerLabel("yfinance"), "Yahoo snapshot");
assert.equal(providerLabel(null), "Snapshot");
});
test("normalizeMetric keeps only GEX vs VEX", () => {
assert.equal(normalizeMetric("gex"), "gex");
assert.equal(normalizeMetric("vex"), "vex");
assert.equal(normalizeMetric("oi"), "gex");
assert.equal(normalizeMetric(null), "gex");
});
test("scenarioLine joins pattern names without chip soup", () => {
assert.equal(
scenarioLine("pos_gex_pos_vex", [{ id: "whipsaw", confidence: 0.6 }]),
"Calm · Whipsaw",
);
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { computeEma921Range } from "../lib/ema921Range.ts";
function bar(c: number): { o: number; h: number; l: number; c: number } {
return { o: c, h: c + 0.4, l: c - 0.4, c };
}
/** 21 bars at 100 seed both EMAs, then a step up so 9 crosses above 21. */
function stepUp(): Array<{ o: number; h: number; l: number; c: number }> {
return [
...Array.from({ length: 21 }, () => bar(100)),
...Array.from({ length: 15 }, () => bar(120)),
];
}
/** After an up-cross, a step down so 21 crosses back over 9. */
function stepUpThenDown(): Array<{ o: number; h: number; l: number; c: number }> {
return [
...Array.from({ length: 21 }, () => bar(100)),
...Array.from({ length: 20 }, () => bar(130)),
...Array.from({ length: 20 }, () => bar(70)),
];
}
describe("computeEma921Range", () => {
it("posts no levels when there is no 9/21 cross", () => {
const candles = Array.from({ length: 30 }, (_, i) => {
const c = 50 + i * 0.01;
return { o: c, h: c, l: c, c };
});
const r = computeEma921Range(candles, 30);
assert.equal(r.bars.some((b) => b.cross != null), false);
assert.equal(r.levels.up1, null);
assert.equal(r.levels.down1, null);
});
it("posts two up levels above the cross-up open", () => {
const candles = stepUp();
const r = computeEma921Range(candles, 500);
const upAt = r.bars.findIndex((b) => b.cross === "up");
assert.ok(upAt > 0, "expected a 9-over-21");
assert.ok(r.levels.up1 != null && r.levels.up2 != null);
assert.equal(r.levels.upFromIndex, upAt);
const open = candles[upAt]!.o;
assert.ok(r.levels.up1! >= open);
assert.ok(r.levels.up2! >= r.levels.up1!);
});
it("a later 9-over-21 replaces the up pair", () => {
const candles = [
...Array.from({ length: 21 }, () => bar(100)),
...Array.from({ length: 15 }, () => bar(130)),
...Array.from({ length: 15 }, () => bar(80)),
...Array.from({ length: 15 }, () => bar(140)),
];
const r = computeEma921Range(candles, 500);
const ups = r.bars.map((b, i) => (b.cross === "up" ? i : -1)).filter((i) => i >= 0);
assert.ok(ups.length >= 2, `expected 2+ up crosses, got ${ups.length}`);
assert.equal(r.levels.upFromIndex, ups[ups.length - 1]);
});
it("a 21-over-9 posts the down pair and keeps the last up pair", () => {
const candles = stepUpThenDown();
const r = computeEma921Range(candles, 500);
const downAt = r.bars.findIndex((b) => b.cross === "down");
assert.ok(downAt > 0, "expected a 21-over-9");
assert.ok(r.levels.down1 != null && r.levels.down2 != null);
assert.equal(r.levels.downFromIndex, downAt);
assert.ok(r.levels.up1 != null, "up pair should remain");
assert.ok(r.levels.down2! <= r.levels.down1!);
});
it("lookback ignores crosses older than the window", () => {
const candles = Array.from({ length: 80 }, (_, i) => {
const c = i < 20 ? 60 + i : i < 40 ? 80 - (i - 20) : 60 + (i - 40) * 0.2;
return { o: c, h: c + 0.3, l: c - 0.3, c };
});
const full = computeEma921Range(candles, 500);
const tight = computeEma921Range(candles, 15);
const lastDown = [...full.bars].map((b, i) => (b.cross === "down" ? i : -1)).filter((i) => i >= 0).pop();
if (lastDown != null && lastDown < candles.length - 15) {
assert.equal(tight.levels.downFromIndex, null);
}
assert.equal(tight.bars.length, full.bars.length);
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { lastUpdatedText, quoteStampTone } from "../lib/lastUpdatedCopy.ts";
describe("lastUpdatedText", () => {
it("distinguishes queued vs pulled vs failed", () => {
assert.equal(lastUpdatedText({ label: "X", status: "pending" }), "X queued, not pulled yet");
assert.equal(lastUpdatedText({ label: "X", status: "in_flight" }), "X pulling now");
assert.equal(lastUpdatedText({ label: "X", status: "none" }), "X never pulled");
assert.match(
lastUpdatedText({ label: "X", status: "backoff", at: new Date(Date.now() - 60_000).toISOString(), error: "timeout" }),
/X last try failed 1m ago \(timeout\)/,
);
});
});
describe("quoteStampTone", () => {
const now = Date.parse("2026-08-17T16:54:00.000Z");
it("marks missing or old prints stale", () => {
assert.equal(quoteStampTone(null, now), "stale");
assert.equal(quoteStampTone("2026-08-17T11:55:00.000Z", now), "stale");
assert.equal(quoteStampTone("2026-08-17T16:53:30.000Z", now), "ok");
});
});
@@ -0,0 +1,105 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
emaRelation,
layoutCandle,
priceExtents,
ribbonPath,
ribbonRuns,
} from "../lib/priceChartGeometry.ts";
describe("layoutCandle", () => {
const yOf = (p: number) => 100 - p;
it("maps an up bar to a hollow-ready body between open and close", () => {
const c = layoutCandle({ o: 10, h: 20, l: 8, c: 16 }, 50, yOf, 6);
assert.ok(c);
assert.equal(c.up, true);
assert.equal(c.cx, 50);
assert.equal(c.wickTop, 80);
assert.equal(c.wickBottom, 92);
assert.equal(c.bodyX, 47);
assert.equal(c.bodyY, 84);
assert.equal(c.bodyH, 6);
assert.equal(c.bodyW, 6);
});
it("keeps a doji body at least 1px tall", () => {
const c = layoutCandle({ o: 12, h: 14, l: 10, c: 12 }, 0, yOf, 4);
assert.ok(c);
assert.equal(c.bodyH, 1);
assert.equal(c.up, true);
});
it("returns null when a price is missing", () => {
assert.equal(layoutCandle({ o: 10, h: NaN, l: 8, c: 11 }, 0, yOf, 4), null);
});
});
describe("priceExtents", () => {
it("pads high/low and folds in visible EMAs", () => {
const { minP, maxP } = priceExtents(
[{ h: 20, l: 10, ema9: 22, ema21: 9, ema200: 100 }],
{ ema9: true, ema21: true, ema200: false },
0.1,
);
assert.ok(maxP > 22);
assert.ok(minP < 9);
assert.ok(maxP < 100);
});
it("returns a fallback domain when empty", () => {
assert.deepEqual(priceExtents([]), { minP: 0, maxP: 1 });
});
it("pads after folding annotation prices so a top range line is not flush", () => {
const { minP, maxP } = priceExtents(
[{ h: 20, l: 10 }],
{ extras: [28, 6] },
0.1,
);
assert.ok(maxP > 28);
assert.ok(minP < 6);
assert.ok(maxP - 28 >= (28 - 6) * 0.1 - 1e-9);
});
});
describe("emaRelation", () => {
it("classifies 9 vs 21", () => {
assert.equal(emaRelation(11, 10), "above");
assert.equal(emaRelation(9, 10), "below");
assert.equal(emaRelation(10, 10), "even");
assert.equal(emaRelation(undefined, 10), null);
});
});
describe("ribbonRuns", () => {
it("splits at the interpolated 9/21 cross", () => {
const runs = ribbonRuns([
{ x: 0, ema9: 12, ema21: 10 },
{ x: 10, ema9: 8, ema21: 10 },
]);
assert.equal(runs.length, 2);
assert.equal(runs[0]!.above, true);
assert.equal(runs[1]!.above, false);
const cross = runs[0]!.samples[runs[0]!.samples.length - 1]!;
assert.ok(Math.abs(cross.x - 5) < 1e-9);
assert.ok(Math.abs(cross.ema9 - 10) < 1e-9);
assert.equal(cross.ema9, cross.ema21);
});
it("needs two valid samples", () => {
assert.equal(ribbonRuns([{ x: 0, ema9: 1, ema21: 2 }]).length, 0);
});
it("builds a closed path", () => {
const d = ribbonPath(
[
{ x: 0, ema9: 4, ema21: 2 },
{ x: 10, ema9: 6, ema21: 3 },
],
(p) => p,
);
assert.equal(d, "M0,4L10,6L10,3L0,2Z");
});
});
@@ -44,8 +44,13 @@ test("panel/page/client source pass the Primary-Rule lint", () => {
"../lib/trpc.ts",
"../stores/active-symbol-store.ts",
"../components/dealer-flow/DealerFlowView.tsx",
"../components/dealer-flow/DealerHeatmap.tsx",
"../components/dealer-flow/DealerLevelsStrip.tsx",
"../components/dealer-flow/StudyDeskPanel.tsx",
"../lib/dealerMapChrome.ts",
"../app/dealer-flow/page.tsx",
"../components/ConfluencePanel.tsx",
"../app/confluence/page.tsx",
];
for (const f of files) {
const src = readFileSync(join(__dirname, f), "utf8");
+45
View File
@@ -0,0 +1,45 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { formatCountdown, formatRelativeTime, remainingMsUntil } from "../lib/relativeTime.ts";
describe("formatCountdown", () => {
it("formats remaining windows without rounding away seconds", () => {
assert.equal(formatCountdown(0), "0s");
assert.equal(formatCountdown(-12), "0s");
assert.equal(formatCountdown(1), "1s");
assert.equal(formatCountdown(32_000), "32s");
assert.equal(formatCountdown(4 * 60_000 + 32_000), "4m 32s");
assert.equal(formatCountdown(60 * 60_000), "1h");
assert.equal(formatCountdown(72 * 60_000 + 5_000), "1h 12m");
});
});
describe("remainingMsUntil", () => {
const now = Date.parse("2026-08-17T15:30:00.000Z");
it("returns zero for missing, invalid, or past stamps", () => {
assert.equal(remainingMsUntil(null, now), 0);
assert.equal(remainingMsUntil("not-a-date", now), 0);
assert.equal(remainingMsUntil("2026-08-17T15:29:00.000Z", now), 0);
});
it("returns remaining milliseconds for a future stamp", () => {
assert.equal(remainingMsUntil("2026-08-17T15:34:32.000Z", now), 4 * 60_000 + 32_000);
});
});
describe("formatRelativeTime", () => {
const now = Date.parse("2026-08-16T15:30:00.000Z");
it("labels missing and invalid stamps", () => {
assert.equal(formatRelativeTime(null, now), "never");
assert.equal(formatRelativeTime("not-a-date", now), "unknown");
});
it("uses compact units", () => {
assert.equal(formatRelativeTime("2026-08-16T15:29:40.000Z", now), "just now");
assert.equal(formatRelativeTime("2026-08-16T15:18:00.000Z", now), "12m ago");
assert.equal(formatRelativeTime("2026-08-16T13:30:00.000Z", now), "2h ago");
assert.equal(formatRelativeTime("2026-08-15T15:30:00.000Z", now), "1d ago");
});
});
+119 -9
View File
@@ -1,8 +1,10 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { AdminLayout } from "@/components/AdminLayout";
import { api } from "@/lib/trpc";
import { api, type SourceCooldown } from "@/lib/trpc";
import { InfoTooltip } from "@/components/InfoTooltip";
import { formatCountdown, remainingMsUntil } from "@/lib/relativeTime";
import { useVisibilityAwarePoll } from "@/lib/useVisibilityAwarePoll";
interface QueueItem {
source: string;
@@ -24,8 +26,13 @@ interface QueueStatus {
last_error: string | undefined;
counts: Record<string, number>;
lastErrors: Array<{ key: string; error: string | null }>;
sourceCooldowns?: SourceCooldown[];
pausedSources?: string[];
stoppedSources?: string[];
pendingByKind?: Record<string, number>;
demandSize?: number;
dataPlaneHealthy?: boolean;
dataPlaneNotes?: string[];
}
interface ErrorLogEntry {
@@ -93,6 +100,12 @@ function msToHuman(ms: number): string {
return `${Math.round(ms / 86400000)}d`;
}
function cooldownRemainingMs(cool: SourceCooldown | undefined, now: number): number {
if (!cool) return 0;
if (cool.until) return remainingMsUntil(cool.until, now);
return Math.max(0, cool.remainingMs);
}
export default function QueuePage() {
const [queue, setQueue] = useState<QueueItem[]>([]);
const [status, setStatus] = useState<QueueStatus | null>(null);
@@ -126,6 +139,7 @@ export default function QueuePage() {
mode: string;
} | null>(null);
const [clearingCooldown, setClearingCooldown] = useState<string | null>(null);
const [resettingSource, setResettingSource] = useState<string | null>(null);
const [resetResult, setResetResult] = useState<Record<string, number>>({});
const [controllingSource, setControllingSource] = useState<{ source: string; action: string } | null>(null);
@@ -154,6 +168,7 @@ export default function QueuePage() {
const [pausing, setPausing] = useState(false);
const [restarting, setRestarting] = useState(false);
const [nowMs, setNowMs] = useState(() => Date.now());
const loadData = useCallback(async () => {
try {
@@ -183,6 +198,8 @@ export default function QueuePage() {
return () => { clearTimeout(boot); clearInterval(interval); };
}, [loadData]);
useVisibilityAwarePoll(() => setNowMs(Date.now()), 1000);
const handlePause = async () => {
setPausing(true);
try {
@@ -302,6 +319,20 @@ export default function QueuePage() {
}
};
const handleClearCooldown = async (sourceKind: string) => {
setClearingCooldown(sourceKind);
setError(null);
try {
await api.admin.clearSourceCooldown(sourceKind);
setSuccess(`${sourceKind} cooldown cleared`);
await loadData();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to clear cooldown");
} finally {
setClearingCooldown(null);
}
};
const handleResetBackoff = async (sourceKind: string) => {
setResettingSource(sourceKind);
try {
@@ -425,7 +456,23 @@ export default function QueuePage() {
}
}
const cooldownBySource: Record<string, SourceCooldown> = {};
for (const cool of status?.sourceCooldowns ?? []) {
if (!cool.active && cooldownRemainingMs(cool, nowMs) <= 0) continue;
cooldownBySource[cool.source] = cool;
if (!sources[cool.source]) {
sources[cool.source] = { pending: 0, inFlight: 0, done: 0, backoff: 0, failed: 0, lastAttempt: null, hasBackoff: false };
}
}
const sortedSources = Object.entries(sources).sort(([a], [b]) => a.localeCompare(b));
const activeCooldowns = sortedSources
.map(([source]) => {
const cool = cooldownBySource[source];
const remaining = cooldownRemainingMs(cool, nowMs);
return cool && remaining > 0 ? { source, cool, remaining } : null;
})
.filter((row): row is { source: string; cool: SourceCooldown; remaining: number } => row != null);
const sourceControl: Record<string, "running" | "paused" | "stopped"> = {};
for (const s of status?.stoppedSources ?? []) sourceControl[s] = "stopped";
@@ -511,7 +558,7 @@ export default function QueuePage() {
<p className="text-xs text-fg-faint italic py-8 text-center">No queue data available</p>
) : (
<>
<div className="flex items-center gap-4 px-4 pt-3 border-b border-[#1a1b28]">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 px-4 pt-3 border-b border-[#1a1b28]">
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-accent" /> Pending
<InfoTooltip>Jobs waiting to be picked up by the next drain cycle.</InfoTooltip>
@@ -528,20 +575,43 @@ export default function QueuePage() {
<span className="h-1.5 w-1.5 rounded-full bg-[#fb923c]" /> Backoff
<InfoTooltip>A vendor rate-limit or cool-down. Jobs stall and retry automatically once the window passes.</InfoTooltip>
</span>
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#fbbf24]" /> Cooldown
<InfoTooltip>Source-wide pause after a 429. Remaining time until this source accepts outbound calls again.</InfoTooltip>
</span>
<span className="text-[10px] uppercase tracking-wide text-fg-faint flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-danger" /> Failed
<InfoTooltip>Permanent failures (quarantine / max retries exceeded). See the Failed Jobs log below for details and manual retry.</InfoTooltip>
</span>
</div>
{sortedSources.some(([, v]) => v.backoff > 0) && (
<div className="mx-4 mt-3 flex items-center gap-2 rounded border border-orange-500/30 bg-orange-950/20 px-3 py-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#fb923c] animate-pulse" />
<span className="text-xs text-[#fb923c] font-medium">Backoff active — jobs stalled and awaiting retry</span>
{status && status.dataPlaneHealthy === false && (status.dataPlaneNotes?.length ?? 0) > 0 && (
<div className="mx-4 mt-3 flex items-center gap-2 rounded border border-amber-500/30 bg-amber-950/20 px-3 py-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#fbbf24] animate-pulse" />
<span className="text-xs text-[#fbbf24] font-medium">
Data plane: {status.dataPlaneNotes!.join(" · ")}
</span>
</div>
)}
<table className="w-full text-xs mt-3">
{activeCooldowns.length > 0 && (
<div className="mx-4 mt-3 flex items-center gap-2 rounded border border-amber-500/30 bg-amber-950/20 px-3 py-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#fbbf24] animate-pulse" />
<span className="text-xs text-[#fbbf24] font-medium">
Cooldown active: {activeCooldowns.map((c) => `${c.source} ${formatCountdown(c.remaining)}`).join(", ")}
</span>
</div>
)}
{sortedSources.some(([, v]) => v.backoff > 0) && (
<div className="mx-4 mt-3 flex items-center gap-2 rounded border border-orange-500/30 bg-orange-950/20 px-3 py-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[#fb923c] animate-pulse" />
<span className="text-xs text-[#fb923c] font-medium">Backoff active - jobs stalled and awaiting retry</span>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-xs mt-3 min-w-[52rem]">
<thead>
<tr className="border-b border-line-strong text-left text-fg-faint uppercase tracking-wide text-[10px]">
<th className="pb-2 pr-4 pl-4 font-medium w-48">
@@ -554,6 +624,7 @@ export default function QueuePage() {
<th className="pb-2 pr-4 font-medium text-right"><span className="text-up inline-flex items-center gap-1">In-flight<InfoTooltip>Jobs actively being fetched right now.</InfoTooltip></span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-fg-muted/60 inline-flex items-center gap-1">Done<InfoTooltip>Completed and cached successfully.</InfoTooltip></span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-[#fb923c] inline-flex items-center gap-1">Backoff<InfoTooltip>Rate-limited; auto-retries after cool-down.</InfoTooltip></span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-[#fbbf24] inline-flex items-center gap-1">Cooldown<InfoTooltip>Live remaining time on the source-wide 429 pause. Hits show how far up the 2/5/15/30/60m ladder this source is.</InfoTooltip></span></th>
<th className="pb-2 pr-4 font-medium text-right"><span className="text-danger inline-flex items-center gap-1">Failed<InfoTooltip>Permanent failures requiring manual retry.</InfoTooltip></span></th>
<th className="pb-2 pr-4 font-medium"><span className="inline-flex items-center gap-1">Last Attempt<InfoTooltip>Most recent time any job for this source was attempted.</InfoTooltip></span></th>
<th className="pb-2 font-medium w-72 text-center">
@@ -567,8 +638,11 @@ export default function QueuePage() {
<tbody className="divide-y divide-[#1a1b28]">
{sortedSources.map(([source, agg]) => {
const ctrl = sourceControl[source] ?? "running";
const cool = cooldownBySource[source];
const coolMs = cooldownRemainingMs(cool, nowMs);
const cooling = coolMs > 0;
return (
<tr key={source} className={`hover:bg-surface/50 transition-colors ${agg.hasBackoff ? "bg-orange-950/10" : ""} ${ctrl === "stopped" ? "bg-red-950/10" : ""} ${ctrl === "paused" ? "bg-orange-950/5" : ""}`}>
<tr key={source} className={`hover:bg-surface/50 transition-colors ${cooling ? "bg-amber-950/10" : ""} ${agg.hasBackoff ? "bg-orange-950/10" : ""} ${ctrl === "stopped" ? "bg-red-950/10" : ""} ${ctrl === "paused" ? "bg-orange-950/5" : ""}`}>
<td className="py-2.5 pr-4 pl-4 font-mono text-fg">
<span className="inline-flex items-center gap-1.5">
{source}
@@ -584,6 +658,28 @@ export default function QueuePage() {
<td className={`py-2.5 pr-4 font-mono text-right ${agg.inFlight > 0 ? "text-up" : "text-fg-faint"}`}>{agg.inFlight}</td>
<td className="py-2.5 pr-4 font-mono text-right text-fg-muted">{agg.done}</td>
<td className="py-2.5 pr-4 font-mono text-right text-[#fb923c]">{agg.backoff}</td>
<td className={`py-2.5 pr-4 font-mono text-right whitespace-nowrap ${cooling || (source === "yfinance" && (status?.pendingByKind?.quote ?? 0) > 0) ? "text-[#fbbf24]" : "text-fg-faint"}`}>
{cooling ? (
<span
title={
cool?.until
? `Until ${cool.until}${cool.consecutiveHits > 0 ? ` · ${cool.consecutiveHits} consecutive hit${cool.consecutiveHits === 1 ? "" : "s"}` : ""}`
: undefined
}
>
{formatCountdown(coolMs)}
{cool && cool.consecutiveHits > 1 ? (
<span className="ml-1 text-[9px] text-fg-faint"> ×{cool.consecutiveHits}</span>
) : null}
</span>
) : source === "yfinance" && (status?.pendingByKind?.quote ?? 0) > 0 ? (
<span title="No 429 pause. Yahoo is fetching, but quotes are queued behind other work.">
{status?.pendingByKind?.quote}q
</span>
) : (
"-"
)}
</td>
<td className={`py-2.5 pr-4 font-mono text-right ${agg.failed > 0 ? "text-danger" : "text-fg-faint"}`}>{agg.failed}</td>
<td className="py-2.5 pr-4 text-[10px] text-fg-faint whitespace-nowrap font-mono">{formatTimestamp(agg.lastAttempt)}</td>
<td className="py-2.5 text-center">
@@ -630,8 +726,21 @@ export default function QueuePage() {
</>
)}
</div>
{(agg.hasBackoff || agg.failed > 0) && (
{(cooling || agg.hasBackoff || agg.failed > 0) && (
<div className="flex items-center justify-center gap-1.5">
{cooling && (
<button
onClick={() => handleClearCooldown(source)}
disabled={clearingCooldown === source}
className="inline-flex items-center justify-center gap-1 rounded border border-amber-500/40 bg-amber-950/30 px-2 py-1 text-[10px] font-medium text-[#fbbf24] hover:bg-amber-950/60 transition-colors disabled:opacity-40 disabled:cursor-not-allowed min-w-[64px]"
title="Clear the source-wide 429 pause and vendor gate so this source can drain again"
>
{clearingCooldown === source ? (
<span className="h-3 w-3 border border-[#fbbf24] border-t-transparent rounded-full animate-spin" />
) : null}
Clear
</button>
)}
{agg.hasBackoff && (
<button
onClick={() => handleResetBackoff(source)}
@@ -664,6 +773,7 @@ export default function QueuePage() {
})}
</tbody>
</table>
</div>
{sortedSources.some(([, v]) => v.failed > 0) && (
<div className="mx-4 mb-3 mt-2 flex items-center gap-2 rounded border border-red-500/30 bg-red-950/20 px-3 py-1.5">
+15 -1
View File
@@ -3,6 +3,8 @@ import { useEffect, useState } from "react";
import { AdminLayout } from "@/components/AdminLayout";
import { api } from "@/lib/trpc";
import type { XAccountRow, XCredentialsStatus } from "@/lib/trpc";
import { LastUpdated } from "@/components/shared/LastUpdated";
import type { PullStatus } from "@/components/shared/LastUpdated";
/** Operator tooling: manage X cookie credentials + tracked accounts per symbol. */
export default function XAccountsPage() {
@@ -204,18 +206,30 @@ export default function XAccountsPage() {
<th className="px-3 py-2 text-left font-medium">Symbol</th>
<th className="px-3 py-2 text-left font-medium">@ handle</th>
<th className="px-3 py-2 text-left font-medium">Label</th>
<th className="px-3 py-2 text-left font-medium">Last pull</th>
<th className="px-3 py-2 text-left font-medium">Last post</th>
<th className="px-3 py-2 text-right font-medium w-[100px]"></th>
</tr>
</thead>
<tbody className="divide-y divide-line-strong">
{accounts.length === 0 && (
<tr><td colSpan={4} className="px-3 py-6 text-center text-fg-muted text-xs">No tracked accounts yet. Add one above.</td></tr>
<tr><td colSpan={6} className="px-3 py-6 text-center text-fg-muted text-xs">No tracked accounts yet. Add one above.</td></tr>
)}
{accounts.map((a) => (
<tr key={a.id} className="hover:bg-surface-overlay/50 transition-colors">
<td className="px-3 py-2 font-mono text-fg">{a.symbol}</td>
<td className="px-3 py-2 text-link">@{a.handle}</td>
<td className="px-3 py-2 text-fg-muted">{a.label || "—"}</td>
<td className="px-3 py-2">
<LastUpdated
at={a.pullAt ?? null}
status={(a.pullStatus as PullStatus | undefined) ?? "none"}
error={a.pullError ?? null}
/>
</td>
<td className="px-3 py-2">
<LastUpdated at={a.lastPostAt ?? null} status={a.lastPostAt ? "done" : "none"} />
</td>
<td className="px-3 py-2 text-right">
<button onClick={() => handleRemoveAccount(a.id)} className="text-xs text-error hover:underline">Remove</button>
</td>
+9 -40
View File
@@ -1,16 +1,10 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ExternalLink as ExternalLinkIcon } from "lucide-react";
import { LayoutShell } from "@/components/LayoutShell";
import { FeatureGate } from "@/components/FeatureGate";
import { AlertEventCard } from "@/components/AlertEventCard";
import { api, type AlertEventRow, type AlertSubscriptionRow, type AlertTypeToggleRow } from "@/lib/trpc";
const SEVERITY_COLORS: Record<string, string> = {
info: "text-blue-400 bg-blue-950/30 border-blue-500/30",
warning: "text-amber-400 bg-amber-950/30 border-amber-500/30",
critical: "text-red-400 bg-red-950/30 border-red-500/30",
};
function AlertsPageInner() {
const [tab, setTab] = useState<"events" | "subscriptions">("events");
const [events, setEvents] = useState<AlertEventRow[]>([]);
@@ -22,6 +16,7 @@ function AlertsPageInner() {
const [clearing, setClearing] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const [totalCount, setTotalCount] = useState<number | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const confirmTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadEvents = async () => {
@@ -199,39 +194,13 @@ function AlertsPageInner() {
) : (
<div className="space-y-2">
{events.map((e) => (
<div key={e.id} className={`rounded-lg border p-3.5 sm:p-4 ${e.acknowledged ? "border-line opacity-50" : "border-line"}`}>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`rounded border px-1.5 py-0.5 text-xs font-medium ${SEVERITY_COLORS[e.severity] ?? ""}`}>{e.severity}</span>
<span className="text-sm font-medium text-fg">{e.title}</span>
{e.symbol && <span className="rounded bg-surface-sunken px-1.5 py-0.5 text-xs text-fg-faint font-mono">{e.symbol}</span>}
</div>
<p className="text-sm text-fg-faint whitespace-pre-wrap">{e.description}</p>
{typeof e.payload?.url === "string" && (
<a
href={e.payload.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1.5 inline-flex items-center gap-1 text-xs text-accent hover:text-accent-hover hover:underline"
>
View filing on SEC.gov
<ExternalLinkIcon className="h-3 w-3" />
</a>
)}
<p className="mt-1 text-xs text-fg-faint/60">{new Date(e.createdAt).toLocaleString()}</p>
</div>
{!e.acknowledged && (
<button
type="button"
onClick={() => acknowledge(e.id)}
className="shrink-0 min-h-[44px] sm:min-h-0 w-full sm:w-auto rounded-md border border-line px-4 py-2 text-sm sm:text-xs text-fg-muted hover:text-fg active:bg-surface-sunken"
>
Dismiss
</button>
)}
</div>
</div>
<AlertEventCard
key={e.id}
alert={e}
expanded={expandedId === e.id}
onToggle={() => setExpandedId((id) => (id === e.id ? null : e.id))}
onDismiss={e.acknowledged ? undefined : () => void acknowledge(e.id)}
/>
))}
</div>
)}
+9 -1
View File
@@ -13,6 +13,9 @@ const TABS = [
* Confluence — entry/exit picture quality from the confluence rack, plus the
* Price Corridor view (valuation corridor + corridor-method backtest ledger).
* Evidence-based descriptions of a symbol's current setup (ADR-0007).
*
* Both tabs stay mounted and follow the shell's active symbol. Switching tabs
* must not remount a panel onto a default ticker.
*/
export default function ConfluencePage() {
const [tab, setTab] = useState<'confluence' | 'corridor'>('confluence');
@@ -33,7 +36,12 @@ export default function ConfluencePage() {
</button>
))}
</div>
{tab === 'confluence' ? <ConfluencePanel /> : <CorridorPanel />}
<div hidden={tab !== 'confluence'}>
<ConfluencePanel />
</div>
<div hidden={tab !== 'corridor'}>
<CorridorPanel />
</div>
</LayoutShell>
);
}
+177 -69
View File
@@ -4,14 +4,20 @@ import { useEffect, useState, Fragment } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
import { api } from "@/lib/trpc";
import { api, type FundFreshness } from "@/lib/trpc";
import { LayoutShell } from "@/components/LayoutShell";
import { UI_STRINGS } from "@/lib/strings";
import { LastUpdated } from "@/components/shared/LastUpdated";
interface LivePos {
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";
}
interface DiffRow {
symbol: string; weight: number; fund_avg_cost: number | null; target_value: number;
@@ -27,6 +33,7 @@ const SOURCE_BADGE: Record<string, string> = {
capture: "bg-emerald-100 text-emerald-800",
claim: "bg-sky-100 text-sky-800",
"13f": "bg-gray-100 text-gray-700",
"x only": "bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200",
};
function tweetUrl(evidenceUrl: string | null): string | null {
@@ -40,18 +47,29 @@ export default function FundDetailPage() {
const params = useParams<{ id: string }>();
const id = params.id;
const [book, setBook] = useState<LivePos[]>([]);
const [bookLoading, setBookLoading] = useState(true);
const [bookError, setBookError] = useState<string | null>(null);
const [fundName, setFundName] = useState("");
const [freshness, setFreshness] = useState<FundFreshness | null>(null);
const [base, setBase] = useState(200000);
const [floorOn, setFloorOn] = useState(true);
const [diff, setDiff] = useState<DiffResult | null>(null);
useEffect(() => {
if (!id) return;
api.funds.get({ id }).then((r: unknown) => {
const f = r as { fund_name?: string } | null;
if (f?.fund_name) setFundName(f.fund_name);
setBookLoading(true);
setBookError(null);
api.funds.get({ id }).then((r) => {
if (r?.fund_name) setFundName(r.fund_name);
setFreshness(r?.freshness ?? null);
}).catch(() => {});
api.funds.liveBook({ fundId: id }).then((r: unknown) => setBook((r as LivePos[]) ?? [])).catch(() => {});
api.funds.liveBook({ fundId: id })
.then((r: unknown) => setBook((r as LivePos[]) ?? []))
.catch((e) => {
setBook([]);
setBookError(e instanceof Error ? e.message : "Failed to load book");
})
.finally(() => setBookLoading(false));
}, [id]);
const compare = async () => {
@@ -66,12 +84,105 @@ export default function FundDetailPage() {
};
// Deduplicate to most recent entry per symbol (fixes React duplicate-key warning)
const instKey = (p: LivePos) => `${p.symbol}::${p.notes === "call" || p.notes === "put" ? p.notes : "eq"}`;
const sortedBook = [...book]
.sort((a, b) => b.as_of.localeCompare(a.as_of))
.filter((p, i, arr) => arr.findIndex((q) => q.symbol === p.symbol) === i);
const resetIdx = sortedBook.findIndex((p) => p.notes === 'book_reset');
.filter((p, i, arr) => arr.findIndex((q) => instKey(q) === instKey(p)) === i);
const heldBook = sortedBook.filter((p) => p.status !== "exited" && p.shares !== 0);
const exitedBook = sortedBook.filter((p) => p.status === "exited" || p.shares === 0);
const resetIdx = heldBook.findIndex((p) => p.notes === 'book_reset');
const [tip, setTip] = useState<{ symbol: string; text: string; x: number; y: number } | null>(null);
const changeLabel = (p: LivePos) => {
if (p.change === "new") return { text: "new", cls: "text-fg-muted" };
if (p.change === "flat") return { text: "flat", cls: "text-fg-muted" };
if (p.change === "add" && p.share_delta != null) {
return { text: `+${p.share_delta.toLocaleString()}`, cls: "text-emerald-700" };
}
if (p.change === "cut" && p.share_delta != null) {
return { text: p.share_delta.toLocaleString(), cls: "text-red-700" };
}
return { text: "—", cls: "text-fg-muted" };
};
const renderRow = (p: LivePos, i: number, list: LivePos[], muted = false) => (
<Fragment key={instKey(p)}>
{resetIdx >= 0 && list === heldBook && i === resetIdx && (
<tr>
<td colSpan={6} className="py-2 px-2">
<div className="flex items-center gap-2">
<div className="flex-1 border-t border-dashed border-amber-400"></div>
<span className="text-[10px] uppercase tracking-wide text-amber-600 font-medium whitespace-nowrap">
Book reset — sold almost everything, kept 6 names
</span>
<div className="flex-1 border-t border-dashed border-amber-400"></div>
</div>
</td>
</tr>
)}
<tr
className={`border-b ${muted ? "opacity-60" : ""} ${p.notes === 'pre_reset' ? 'opacity-40' : p.notes === 'book_reset' ? 'bg-amber-50 dark:bg-amber-950/20' : ''} ${tweetUrl(p.evidence_url) && p.evidence_status !== "deleted" ? 'cursor-pointer hover:bg-black/[0.04] dark:hover:bg-white/[0.06]' : ''}`}
onMouseEnter={(e) => {
if (!p.post_text) return;
const rect = e.currentTarget.getBoundingClientRect();
const maxW = Math.min(448, window.innerWidth - 32);
const x = Math.min(rect.left, window.innerWidth - maxW - 16);
const below = rect.bottom + 4;
const y = below + 220 > window.innerHeight ? Math.max(8, rect.top - 224) : below;
setTip({ symbol: p.symbol, text: p.post_text, x: Math.max(8, x), y });
}}
onMouseLeave={() => setTip(null)}
onClick={() => {
if (p.evidence_status === "deleted") return;
const url = tweetUrl(p.evidence_url);
if (url) window.open(url, '_blank', 'noopener,noreferrer');
}}
>
<td className="py-1.5 font-medium">
<span className="inline-flex items-center gap-1.5">
{p.symbol}
{muted && (
<span className="px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wide bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-200">
exited
</span>
)}
{(p.notes === 'call' || p.notes === 'put') && (
<span className="px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wide bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-200">
{p.notes}
</span>
)}
{tweetUrl(p.evidence_url) && p.evidence_status !== "deleted" && (
<ExternalLink className="h-3 w-3 text-fg-muted" aria-hidden />
)}
{p.evidence_status === "deleted" && (
<span className="px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wide bg-stone-200 text-stone-700 dark:bg-stone-800 dark:text-stone-300">
deleted
</span>
)}
</span>
</td>
<td>
{muted
? (p.last_held_shares != null ? p.last_held_shares.toLocaleString() : p.shares != null && p.shares > 0 ? p.shares.toLocaleString() : "—")
: p.shares != null ? p.shares.toLocaleString() : "—"}
</td>
<td>
{muted ? "—" : (
<span className={`tnum ${changeLabel(p).cls}`}>{changeLabel(p).text}</span>
)}
</td>
<td>{p.cost_basis != null ? `$${p.cost_basis}` : "—"}</td>
<td>{p.as_of}</td>
<td>
<span className={`px-2 py-0.5 rounded text-xs ${SOURCE_BADGE[p.x_only ? "x only" : p.source] ?? "bg-gray-100"}`}>
{p.x_only ? "x only" : p.source}
{p.evidence_status === "deleted" ? " · deleted" : ""}
</span>
</td>
</tr>
</Fragment>
);
return (
<LayoutShell>
<div className="space-y-4">
@@ -82,83 +193,80 @@ export default function FundDetailPage() {
<h1 className="text-xl font-semibold text-fg">{fundName || "Fund profile"}</h1>
<p className="text-sm text-fg-muted">
Disclosed book as reported by the fund. Figures are arithmetic derived from disclosed
holdings — this is a calculator, not advice.
holdings - this is a calculator, not advice.
</p>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5">
{freshness?.xHandle && (
<LastUpdated
label={`X @${freshness.xHandle}`}
at={freshness.xPull.lastAttempt}
status={freshness.xPull.status}
error={freshness.xPull.error}
/>
)}
{freshness?.lastPostAt && (
<LastUpdated label="Last post" at={freshness.lastPostAt} status="done" />
)}
<LastUpdated
label="Captures"
at={freshness?.lastCaptureAt ?? null}
status={freshness?.lastCaptureAt ? "done" : "none"}
/>
<LastUpdated
label="13F"
at={freshness?.last13fAt ?? null}
status={freshness?.last13fAt ? "done" : "none"}
/>
</div>
</div>
<section className="rounded-lg border border-line-strong bg-surface-raised p-4">
<h2 className="text-sm font-semibold text-fg mb-2">Live book (most recent disclosure per position)</h2>
<h2 className="text-sm font-semibold text-fg mb-2">Disclosed book</h2>
<p className="text-xs text-fg-muted mb-2">
Latest non-zero disclosure per name. Change is vs the prior disclosure of the same instrument (common vs calls).
Source <span className="font-medium">x only</span> means the name is on X and has never appeared in a 13F for that instrument.
</p>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="text-left border-b">
<th className="py-1">Symbol</th><th>Shares</th><th>Avg cost</th><th>As of</th><th>Source</th>
<th className="py-1">Symbol</th><th>Shares</th><th>Change</th><th>Avg cost</th><th>As of</th><th>Source</th>
</tr>
</thead>
<tbody>
{sortedBook.map((p, i) => (
<Fragment key={p.symbol}>
{resetIdx >= 0 && i === resetIdx && (
<tr>
<td colSpan={5} className="py-2 px-2">
<div className="flex items-center gap-2">
<div className="flex-1 border-t border-dashed border-amber-400"></div>
<span className="text-[10px] uppercase tracking-wide text-amber-600 font-medium whitespace-nowrap">
Book reset — sold almost everything, kept 6 names
</span>
<div className="flex-1 border-t border-dashed border-amber-400"></div>
</div>
</td>
</tr>
)}
<tr
className={`border-b ${p.notes === 'pre_reset' ? 'opacity-40' : p.notes === 'book_reset' ? 'bg-amber-50 dark:bg-amber-950/20' : ''} ${tweetUrl(p.evidence_url) ? 'cursor-pointer hover:bg-black/[0.04] dark:hover:bg-white/[0.06]' : ''}`}
onMouseEnter={(e) => {
if (!p.post_text) return;
const rect = e.currentTarget.getBoundingClientRect();
const maxW = Math.min(448, window.innerWidth - 32);
const x = Math.min(rect.left, window.innerWidth - maxW - 16);
// Prefer below the row; flip above if near bottom of viewport.
const below = rect.bottom + 4;
const y = below + 220 > window.innerHeight ? Math.max(8, rect.top - 224) : below;
setTip({ symbol: p.symbol, text: p.post_text, x: Math.max(8, x), y });
}}
onMouseLeave={() => setTip(null)}
onClick={() => {
const url = tweetUrl(p.evidence_url);
if (url) window.open(url, '_blank', 'noopener,noreferrer');
}}
>
<td className="py-1.5 font-medium">
<span className="inline-flex items-center gap-1.5">
{p.symbol}
{(p.notes === 'call' || p.notes === 'put') && (
<span className="px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wide bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-200">
{p.notes}
</span>
)}
{tweetUrl(p.evidence_url) && (
<ExternalLink className="h-3 w-3 text-fg-muted" aria-hidden />
)}
</span>
</td>
<td>{p.shares != null ? p.shares.toLocaleString() : "—"}</td>
<td>{p.cost_basis != null ? `$${p.cost_basis}` : "—"}</td>
<td>{p.as_of}</td>
<td>
<span className={`px-2 py-0.5 rounded text-xs ${SOURCE_BADGE[p.source] ?? "bg-gray-100"}`}>
{p.source}
</span>
</td>
</tr>
</Fragment>
))}
{book.length === 0 && <tr><td colSpan={5} className="py-2 text-gray-400">No disclosures yet.</td></tr>}
{heldBook.map((p, i) => renderRow(p, i, heldBook))}
{heldBook.length === 0 && (
<tr>
<td colSpan={6} className="py-2 text-fg-muted">
{bookLoading ? "Loading book…" : bookError ? bookError : "No disclosures yet."}
</td>
</tr>
)}
</tbody>
</table>
</section>
{exitedBook.length > 0 && (
<section className="rounded-lg border border-line-strong bg-surface-raised p-4">
<h2 className="text-sm font-semibold text-fg mb-1">Exited</h2>
<p className="text-xs text-fg-muted mb-2">
Omitted from the latest 13F quarter-end snapshot, or the manager reported the position closed on X.
A 13F omission is not a live sale ticket.
</p>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="text-left border-b">
<th className="py-1">Symbol</th><th>Last shares</th><th>Change</th><th>Avg cost</th><th>As of</th><th>Source</th>
</tr>
</thead>
<tbody>
{exitedBook.map((p, i) => renderRow(p, i, exitedBook, true))}
</tbody>
</table>
</section>
)}
<section className="rounded-lg border border-line-strong bg-surface-raised p-4">
<h2 className="text-sm font-semibold text-fg mb-2">Mirror target</h2>
<h2 className="text-sm font-semibold text-fg mb-2">Tracked fund book</h2>
<div className="flex items-center gap-3 mb-3">
<label className="text-sm">Base ($)
<input
+30 -3
View File
@@ -2,12 +2,14 @@
// Mirror Portfolio (M21) — tracked-fund index.
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/trpc";
import { api, type FundFreshness } from "@/lib/trpc";
import { LayoutShell } from "@/components/LayoutShell";
import { LastUpdated } from "@/components/shared/LastUpdated";
interface Fund {
id: string; ci_key: string; fund_name: string; manager_name: string;
x_handle: string | null; paywall_status: string; enabled: boolean;
freshness: FundFreshness | null;
}
const emptyForm = { ci_key: "", fund_name: "", manager_name: "", x_handle: "", paywall_status: "unknown" as string };
@@ -19,7 +21,14 @@ export default function FundsPage() {
const [msg, setMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const load = () => {
api.funds.list().then((r: unknown) => setFunds((r as Fund[]) ?? [])).catch(() => {}).finally(() => setLoading(false));
setLoading(true);
api.funds.list()
.then((r: unknown) => setFunds((r as Fund[]) ?? []))
.catch((e) => {
setFunds([]);
setMsg({ type: "err", text: e instanceof Error ? e.message : "Failed to load funds." });
})
.finally(() => setLoading(false));
};
useEffect(load, []);
@@ -84,7 +93,7 @@ export default function FundsPage() {
<div>
<h1 className="text-xl font-semibold text-fg">Tracked Funds</h1>
<p className="text-sm text-fg-muted">
Operator-curated funds whose disclosed book can be mirrored. This is a calculator — showing arithmetic, not advice.
Operator-curated funds and their disclosed books. This is a calculator - showing arithmetic, not advice.
</p>
</div>
@@ -125,6 +134,24 @@ export default function FundsPage() {
<span className="ml-2 inline-block px-1.5 py-0.5 rounded text-xs bg-surface text-fg-muted border border-line-strong">{f.paywall_status}</span>
{!f.enabled && <span className="ml-2 text-xs text-down">disabled</span>}
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5">
{f.x_handle && (
<LastUpdated
label="X"
at={f.freshness?.xPull.lastAttempt ?? null}
status={f.freshness?.xPull.status}
error={f.freshness?.xPull.error}
/>
)}
{f.freshness?.lastPostAt && (
<LastUpdated label="Last post" at={f.freshness.lastPostAt} status="done" />
)}
<LastUpdated
label="Book"
at={f.freshness?.lastCaptureAt ?? f.freshness?.last13fAt ?? null}
status={f.freshness?.lastCaptureAt || f.freshness?.last13fAt ? "done" : "none"}
/>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => sync13f(f)} className="rounded border border-line-strong px-2 py-1 text-xs text-fg-muted hover:text-fg" title="Pull 13F positions into the live book">Sync 13F</button>
+5
View File
@@ -0,0 +1,5 @@
export const dynamic = "force-dynamic";
export function GET() {
return Response.json({ ok: true, service: "investor-flow-web" });
}
+1 -1
View File
@@ -20,7 +20,7 @@ const LINKS: MoreLink[] = [
{ label: "Get Started", href: "/guided-start", description: "Strategy templates for how you manage the book", module: "execution", navId: "guided-start" },
{ label: "Portfolio", href: "/portfolio", description: "Holdings, marks, and book management", module: "execution", navId: "portfolio" },
{ label: "Risk", href: "/risk", description: "Risk posture and sizing math", module: "execution", navId: "risk" },
{ label: "Tracked Funds", href: "/funds", description: "Mirror a tracked fund's disclosed book", module: "execution", navId: "funds" },
{ label: "Tracked Funds", href: "/funds", description: "A tracked fund's disclosed book", module: "execution", navId: "funds" },
{ label: "Decision plan", href: "/plan", description: "Record planned or active position decisions", module: "execution", navId: "plan" },
{ label: "Theses", href: "/theses", description: "Why you own a name and what invalidates it", module: "execution", navId: "theses" },
{ label: "Journal", href: "/journal", description: "Close notes and decision log", module: "execution", navId: "journal" },
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { ChevronDown, ExternalLink as ExternalLinkIcon } from "lucide-react";
import type { AlertEventRow } from "@/lib/trpc";
const SEVERITY_COLORS: Record<string, string> = {
info: "text-blue-400 bg-blue-950/30 border-blue-500/30",
warning: "text-amber-400 bg-amber-950/30 border-amber-500/30",
critical: "text-red-400 bg-red-950/30 border-red-500/30",
};
export function alertMessageNeedsExpand(description: string): boolean {
return description.includes("\n") || description.length > 140;
}
interface AlertEventCardProps {
alert: AlertEventRow;
expanded: boolean;
onToggle: () => void;
onDismiss?: () => void;
compact?: boolean;
}
export function AlertEventCard({
alert,
expanded,
onToggle,
onDismiss,
compact = false,
}: AlertEventCardProps) {
const expandable = alertMessageNeedsExpand(alert.description);
const showFull = !expandable || expanded;
const filingUrl = typeof alert.payload?.url === "string" ? alert.payload.url : null;
return (
<div
className={
compact
? "px-4 py-3 hover:bg-surface-sunken transition-colors"
: `rounded-lg border p-3.5 sm:p-4 ${alert.acknowledged ? "border-line opacity-50" : "border-line hover:bg-surface-sunken/50"}`
}
>
<div className={compact ? "flex items-start justify-between gap-2" : "flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3"}>
<div className="min-w-0 flex-1">
{expandable ? (
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="w-full min-w-0 text-left rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<AlertHeader alert={alert} compact={compact} />
<AlertDescription alert={alert} compact={compact} showFull={showFull} />
<ExpandHint expanded={expanded} compact={compact} />
</button>
) : (
<>
<AlertHeader alert={alert} compact={compact} />
<AlertDescription alert={alert} compact={compact} showFull />
</>
)}
{filingUrl ? (
<a
href={filingUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-1.5 inline-flex items-center gap-1 text-xs text-accent hover:text-accent-hover hover:underline"
>
View filing on SEC.gov
<ExternalLinkIcon className="h-3 w-3" />
</a>
) : null}
<p className={compact ? "text-[10px] text-fg-faint/50 mt-0.5" : "mt-1 text-xs text-fg-faint/60"}>
{compact
? new Date(alert.createdAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: new Date(alert.createdAt).toLocaleString()}
</p>
</div>
{onDismiss ? (
<button
type="button"
onClick={onDismiss}
className={
compact
? "shrink-0 min-h-[44px] sm:min-h-0 rounded-md border border-line px-3 py-1.5 text-xs text-fg-faint hover:text-fg hover:bg-surface-dark transition-colors"
: "shrink-0 min-h-[44px] sm:min-h-0 w-full sm:w-auto rounded-md border border-line px-4 py-2 text-sm sm:text-xs text-fg-muted hover:text-fg active:bg-surface-sunken"
}
>
Dismiss
</button>
) : null}
</div>
</div>
);
}
function AlertHeader({ alert, compact }: { alert: AlertEventRow; compact: boolean }) {
return (
<div className={`flex items-center gap-1.5 flex-wrap ${compact ? "mb-0.5" : "mb-1 gap-2"}`}>
<span className={`rounded border font-medium ${SEVERITY_COLORS[alert.severity] ?? ""} ${compact ? "px-1 py-0.5 text-[9px]" : "px-1.5 py-0.5 text-xs"}`}>
{alert.severity}
</span>
<span className={`font-medium text-fg ${compact ? "text-xs break-words" : "text-sm"}`}>{alert.title}</span>
{alert.symbol ? (
<span className={`rounded text-fg-faint ${compact ? "bg-surface-dark px-1 py-0.5 text-[9px]" : "bg-surface-sunken px-1.5 py-0.5 text-xs font-mono"}`}>
{alert.symbol}
</span>
) : null}
</div>
);
}
function AlertDescription({
alert,
compact,
showFull,
}: {
alert: AlertEventRow;
compact: boolean;
showFull: boolean;
}) {
return (
<p
className={`text-fg-faint break-words ${compact ? "text-xs" : "text-sm"} ${
showFull ? "whitespace-pre-wrap" : "line-clamp-2"
}`}
>
{alert.description}
</p>
);
}
function ExpandHint({ expanded, compact }: { expanded: boolean; compact: boolean }) {
return (
<span className={`mt-1 inline-flex items-center gap-0.5 text-accent ${compact ? "text-[10px]" : "text-xs"}`}>
<ChevronDown className={`transition-transform ${compact ? "h-3 w-3" : "h-3.5 w-3.5"} ${expanded ? "rotate-180" : ""}`} />
{expanded ? "Show less" : "Show more"}
</span>
);
}
+292 -92
View File
@@ -1,10 +1,14 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useActiveSymbol } from "@/stores/active-symbol-store";
import { api, type PriceCandle, type IndicatorResult } from "@/lib/trpc";
import { UI_STRINGS } from "@/lib/strings";
import { chart as CHART } from "@/lib/chart-theme";
import { buildVolumeProfile } from "@/lib/volumeProfile";
import { emaRelation, priceExtents } from "@/lib/priceChartGeometry";
import { computeEma921Range } from "@/lib/ema921Range";
import { PriceCandleLayer } from "@/components/chart/PriceCandleLayer";
import { LastUpdated } from "@/components/shared/LastUpdated";
import {
Bar,
BarChart,
@@ -21,32 +25,61 @@ import {
// Chart Lab Panel (Slice 5c). Renders price candles, EMA overlays, volume, RSI,
// relative volume, and equity volume-by-price for the active symbol.
type ChartTf = '1m' | '5m' | '1d' | '1wk' | '1mo';
interface RangeOption {
label: string;
years: number | null; // null = ALL
years: number | null;
days?: number | null;
}
const RANGE_OPTIONS: RangeOption[] = [
const DAILY_RANGE: RangeOption[] = [
{ label: "3M", years: 0.25 },
{ label: "6M", years: 0.5 },
{ label: "1Y", years: 1 },
{ label: "5Y", years: 5 },
{ label: "ALL", years: null },
];
const RANGE_1M: RangeOption[] = [
{ label: "1D", years: null, days: 1 },
{ label: "2D", years: null, days: 2 },
{ label: "5D", years: null, days: 5 },
{ label: "ALL", years: null, days: null },
];
const RANGE_5M: RangeOption[] = [
{ label: "1D", years: null, days: 1 },
{ label: "5D", years: null, days: 5 },
{ label: "1M", years: null, days: 21 },
{ label: "ALL", years: null, days: null },
];
// Convert a range (in years) to a number of candles for the active timeframe,
// so the Range labels stay meaningful across Daily / Weekly / Monthly.
function barsForRange(years: number | null, timeframe: '1d' | '1wk' | '1mo'): number {
if (years === null) return Infinity;
function rangesFor(tf: ChartTf): RangeOption[] {
if (tf === '1m') return RANGE_1M;
if (tf === '5m') return RANGE_5M;
return DAILY_RANGE;
}
function defaultRange(tf: ChartTf): string {
if (tf === '1m') return '1D';
if (tf === '5m') return '5D';
return '1Y';
}
function barsForYears(years: number | null, timeframe: ChartTf): number {
if (years == null) return Infinity;
const perYear = timeframe === '1mo' ? 12 : timeframe === '1wk' ? 52 : 252;
return Math.round(years * perYear);
}
function formatCandleDate(ts: string): string {
function formatCandleDate(ts: string, tf: ChartTf): string {
try {
const d = new Date(ts);
if (tf === '1m' || tf === '5m') {
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
} catch {
return ts.slice(0, 10);
return ts.slice(0, 16);
}
}
@@ -80,6 +113,8 @@ interface ChartDataPoint {
macdLine?: number;
signalLine?: number;
histogram?: number;
sentiment?: 'above' | 'below' | 'between' | null;
cross?: 'up' | 'down' | null;
}
interface BuyEvent {
@@ -89,19 +124,19 @@ interface BuyEvent {
date: string;
}
type SeriesKey = 'close' | 'ema9' | 'ema21' | 'ema50' | 'ema200' | 'instBuys';
type SeriesKey = 'candles' | 'ema9' | 'ema21' | 'ema50' | 'ema200' | 'instBuys';
const SERIES_LABELS: Record<SeriesKey, string> = {
close: 'Close',
ema9: 'EMA 9',
ema21: 'EMA 21',
ema50: 'EMA 50',
ema200: 'EMA 200',
candles: UI_STRINGS.candlesLabel,
ema9: UI_STRINGS.ema9Label,
ema21: UI_STRINGS.ema21Label,
ema50: UI_STRINGS.ema50Label,
ema200: UI_STRINGS.ema200Label,
instBuys: 'Institutional Buys',
};
const SERIES_COLORS: Record<SeriesKey, string> = {
close: CHART.accent,
candles: CHART.accent,
ema9: CHART.emaColors[9],
ema21: CHART.emaColors[21],
ema50: CHART.emaColors[50],
@@ -109,47 +144,101 @@ const SERIES_COLORS: Record<SeriesKey, string> = {
instBuys: '#34d399',
};
function PriceTooltip({
active,
payload,
label,
visible,
}: {
active?: boolean;
payload?: Array<{ payload: ChartDataPoint }>;
label?: string;
visible: Record<SeriesKey, boolean>;
}) {
if (!active || !payload?.length) return null;
const d = payload[0]!.payload;
const row = (k: string, v: string, color?: string) => (
<div className="flex justify-between gap-4">
<span className="text-fg-muted">{k}</span>
<span className="tabular-nums text-fg" style={color ? { color } : undefined}>{v}</span>
</div>
);
return (
<div className="rounded border border-line bg-surface-raised px-3 py-2 text-xs shadow-lg">
<div className="font-medium text-fg mb-1">{d.date}</div>
{row("Open", `$${d.o.toFixed(2)}`)}
{row("High", `$${d.h.toFixed(2)}`)}
{row("Low", `$${d.l.toFixed(2)}`)}
{row("Close", `$${d.c.toFixed(2)}`)}
{visible.ema9 && d.ema9 != null && row(UI_STRINGS.ema9Label, `$${d.ema9.toFixed(2)}`, SERIES_COLORS.ema9)}
{visible.ema21 && d.ema21 != null && row(UI_STRINGS.ema21Label, `$${d.ema21.toFixed(2)}`, SERIES_COLORS.ema21)}
{visible.ema50 && d.ema50 != null && row(UI_STRINGS.ema50Label, `$${d.ema50.toFixed(2)}`, SERIES_COLORS.ema50)}
{visible.ema200 && d.ema200 != null && row(UI_STRINGS.ema200Label, `$${d.ema200.toFixed(2)}`, SERIES_COLORS.ema200)}
{visible.instBuys && d.instVol > 0 && row("Inst. volume", formatShares(d.instVol), SERIES_COLORS.instBuys)}
{d.cross === "up" && row(UI_STRINGS.ema921CrossUp, "")}
{d.cross === "down" && row(UI_STRINGS.ema921CrossDown, "")}
</div>
);
}
export function ChartLabPanel() {
const symbol = useActiveSymbol((s) => s.activeSymbol);
const [timeframe, setTimeframe] = useState<'1d' | '1wk' | '1mo'>('1d');
const [timeframe, setTimeframe] = useState<ChartTf>('1d');
const [candles, setCandles] = useState<PriceCandle[]>([]);
const [candlesFetchedAt, setCandlesFetchedAt] = useState<string | null>(null);
const [indicators, setIndicators] = useState<IndicatorResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [buyEvents, setBuyEvents] = useState<BuyEvent[] | null>(null);
const [visibleSeries, setVisibleSeries] = useState<Record<SeriesKey, boolean>>({
close: true, ema9: true, ema21: true, ema50: true, ema200: true, instBuys: false,
candles: true, ema9: true, ema21: true, ema50: false, ema200: false, instBuys: false,
});
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setRange(defaultRange(timeframe));
const loadCandles = api.market.candles(symbol, timeframe).catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : "request failed");
return null;
});
const loadIndicators = api.market.indicators(symbol, {
periods: { ema: [9, 21, 50, 200], rsi: 14, relvol: 20 },
timeframe,
}).catch((e) => {
if (!cancelled) setError(e instanceof Error ? e.message : "request failed");
return null;
});
const loadBuyEvents = api.institutional.buyEvents(symbol).catch(() => null);
Promise.all([loadCandles, loadIndicators, loadBuyEvents]).then(([cData, iData, bData]) => {
if (!cancelled) {
setCandles(Array.isArray(cData) ? cData : []);
const load = (first: boolean) => {
const loadCandles = api.market.candlesWithMeta(symbol, timeframe).catch((e) => {
if (!cancelled && first) setError(e instanceof Error ? e.message : "request failed");
return null;
});
const loadIndicators = api.market.indicators(symbol, {
periods: { ema: [9, 21, 50, 200], rsi: 14, relvol: 20 },
timeframe,
}).catch((e) => {
if (!cancelled && first) setError(e instanceof Error ? e.message : "request failed");
return null;
});
const loadBuyEvents = api.institutional.buyEvents(symbol).catch(() => null);
return Promise.all([loadCandles, loadIndicators, loadBuyEvents]).then(([cData, iData, bData]) => {
if (cancelled) return 0;
const next = Array.isArray(cData?.candles) ? cData.candles : [];
setCandles(next);
setCandlesFetchedAt(cData?.fetchedAt ?? null);
setIndicators(iData);
setBuyEvents(bData?.events ?? null);
setLoading(false);
}
});
return next.length;
});
};
return () => { cancelled = true; };
void load(true);
const minute = timeframe === '1m' || timeframe === '5m';
if (!minute) return () => { cancelled = true; };
const timer = window.setInterval(() => {
void load(false).then((n) => {
if (n > 0) window.clearInterval(timer);
});
}, 3000);
const stop = window.setTimeout(() => window.clearInterval(timer), 120_000);
return () => {
cancelled = true;
window.clearInterval(timer);
window.clearTimeout(stop);
};
}, [symbol, timeframe]);
// Build quarterly institutional buy volume lookup from buy events (13F = quarterly)
@@ -186,7 +275,7 @@ export function ChartLabPanel() {
const chartData: ChartDataPoint[] = useMemo(() => {
return rawCandles.map((c, i) => {
const d: Record<string, any> = {
date: formatCandleDate(c.ts),
date: formatCandleDate(c.ts, timeframe),
ts: c.ts,
o: c.o,
h: c.h,
@@ -211,7 +300,17 @@ export function ChartLabPanel() {
}
return d as ChartDataPoint;
});
}, [rawCandles, rsiSeries, relvolSeries, macdLineSeries, signalLineSeries, histogramSeries, emaArrays, buyVolByQuarter]);
}, [rawCandles, rsiSeries, relvolSeries, macdLineSeries, signalLineSeries, histogramSeries, emaArrays, buyVolByQuarter, timeframe]);
const rangeModel = useMemo(() => computeEma921Range(rawCandles), [rawCandles]);
const chartDataWithRange: ChartDataPoint[] = useMemo(() => {
return chartData.map((d, i) => {
const b = rangeModel.bars[i];
if (!b) return d;
return { ...d, sentiment: b.sentiment, cross: b.cross };
});
}, [chartData, rangeModel]);
function formatVolume(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
@@ -223,12 +322,23 @@ export function ChartLabPanel() {
// only the displayed chart slice is trimmed. Defaults to 1Y.
const [range, setRange] = useState<string>("1Y");
const [showVolumeProfile, setShowVolumeProfile] = useState(true);
const rangeOptions = rangesFor(timeframe);
const displayData: ChartDataPoint[] = useMemo(() => {
if (!chartData.length) return [];
const option = RANGE_OPTIONS.find((o) => o.label === range);
const maxBars = barsForRange(option?.years ?? null, timeframe);
return chartData.slice(-maxBars);
}, [chartData, range, timeframe]);
if (!chartDataWithRange.length) return [];
const option = rangeOptions.find((o) => o.label === range);
if (option?.days != null) {
const lastTs = chartDataWithRange[chartDataWithRange.length - 1]?.ts;
const end = lastTs ? Date.parse(lastTs) : NaN;
if (Number.isFinite(end)) {
const cut = end - option.days * 86_400_000;
return chartDataWithRange.filter((d) => Date.parse(d.ts) >= cut);
}
}
if (option?.years != null) {
return chartDataWithRange.slice(-barsForYears(option.years, timeframe));
}
return chartDataWithRange;
}, [chartDataWithRange, range, timeframe, rangeOptions]);
const volumeProfile = useMemo(
() =>
@@ -245,30 +355,34 @@ export function ChartLabPanel() {
return m || 1;
}, [volumeProfile.bins]);
const tooltipFormatter = useCallback((value: any, name: string) => {
if (!name || name === "date") return [value, ''];
if (name === "c") return [`$${Number(value)?.toFixed(2)}`, "Close"];
if (name === "instVol") return [formatShares(Number(value)), "Institutional Buys"];
if (name.startsWith("ema")) {
const period = name.replace("ema", "");
return [`$${Number(value)?.toFixed(2)}`, `${UI_STRINGS[`ema${period}Label` as keyof typeof UI_STRINGS] ?? `EMA ${period}`}`];
}
return [value, name];
}, []);
const lastBar = displayData[displayData.length - 1];
const lastRelation = emaRelation(lastBar?.ema9, lastBar?.ema21);
const relationLabel =
lastRelation === "above"
? UI_STRINGS.ema9Above21
: lastRelation === "below"
? UI_STRINGS.ema9Below21
: lastRelation === "even"
? UI_STRINGS.ema9Even21
: null;
// Price chart — candlesticks rendered as raw SVG inside <Customized> for correct pixel positioning,
// with EMA lines overlaid via recharts <Line>. Display window trimmed by the range selector.
const { minP: priceMin, maxP: priceMax } = useMemo(() => {
if (!displayData.length) return { minP: 0, maxP: 1 };
let mn = Infinity, mx = -Infinity;
for (const d of displayData) {
if (d.h > mx) mx = d.h;
if (d.l < mn) mn = d.l;
if (d.ema200 !== undefined && d.ema200 > mx) mx = d.ema200;
}
const pad = (mx - mn) * 0.1 || 1;
return { minP: mn - pad, maxP: mx + pad };
}, [displayData]);
return priceExtents(displayData, {
ema9: visibleSeries.ema9,
ema21: visibleSeries.ema21,
ema50: visibleSeries.ema50,
ema200: visibleSeries.ema200,
extras: [
rangeModel.levels.up1,
rangeModel.levels.up2,
rangeModel.levels.down1,
rangeModel.levels.down2,
],
});
}, [displayData, visibleSeries.ema9, visibleSeries.ema21, visibleSeries.ema50, visibleSeries.ema200, rangeModel.levels]);
const upFromDate = rangeModel.levels.upFromIndex != null ? rawCandles[rangeModel.levels.upFromIndex]!.ts : null;
const downFromDate = rangeModel.levels.downFromIndex != null ? rawCandles[rangeModel.levels.downFromIndex]!.ts : null;
const maxInstVol = useMemo(() => {
let mx = 0;
@@ -307,62 +421,148 @@ export function ChartLabPanel() {
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-3">
<h2 className="text-base font-semibold text-fg">{UI_STRINGS.chartsTitle}</h2>
<div className="flex flex-col xs:flex-row sm:flex-row items-stretch sm:items-center gap-2 w-full sm:w-auto">
<div className="inline-flex rounded-md border border-line overflow-hidden text-xs w-full sm:w-auto">
{(['1d', '1wk', '1mo'] as const).map((tf) => (
<button key={tf} type="button" onClick={() => setTimeframe(tf)}
className={`flex-1 sm:flex-none min-h-[40px] sm:min-h-0 px-3 py-2 sm:py-1 ${timeframe === tf ? "bg-accent text-white" : "text-fg-muted hover:text-fg active:bg-surface-sunken"}`}>
{tf === '1d' ? 'Daily' : tf === '1wk' ? 'Weekly' : 'Monthly'}
<div className="inline-flex rounded-md border border-line text-xs w-full sm:w-auto">
{([
{ id: '1m' as const, label: '1m', hint: UI_STRINGS.tfHint1m },
{ id: '5m' as const, label: '5m', hint: UI_STRINGS.tfHint5m },
{ id: '1d' as const, label: 'Daily', hint: UI_STRINGS.tfHint1d },
{ id: '1wk' as const, label: 'Weekly', hint: UI_STRINGS.tfHint1wk },
{ id: '1mo' as const, label: 'Monthly', hint: UI_STRINGS.tfHint1mo },
]).map((tf) => (
<button
key={tf.id}
type="button"
onClick={() => setTimeframe(tf.id)}
className={`group relative flex-1 sm:flex-none min-h-[40px] sm:min-h-0 px-3 py-2 sm:py-1 ${timeframe === tf.id ? "bg-accent text-white" : "text-fg-muted hover:text-fg active:bg-surface-sunken"}`}
>
{tf.label}
<span className="pointer-events-none absolute left-1/2 top-full z-50 mt-1 hidden w-64 -translate-x-1/2 rounded border border-line bg-surface-raised p-2 text-left text-[11px] font-normal leading-snug text-fg-muted shadow-lg group-hover:block group-focus-visible:block">
{tf.hint}
</span>
</button>
))}
</div>
<div className="inline-flex rounded-md border border-line overflow-x-auto text-xs w-full sm:w-auto">
{RANGE_OPTIONS.map((o) => (
<button key={o.label} type="button" onClick={() => setRange(o.label)}
className={`flex-1 sm:flex-none min-h-[40px] sm:min-h-0 px-3 py-2 sm:py-1 ${range === o.label ? "bg-accent text-white" : "text-fg-muted hover:text-fg active:bg-surface-sunken"}`}>
<div className="inline-flex rounded-md border border-line text-xs w-full sm:w-auto">
{rangeOptions.map((o) => {
const hint =
o.label === "1D" ? UI_STRINGS.rangeHint1D
: o.label === "2D" ? UI_STRINGS.rangeHint2D
: o.label === "5D" ? UI_STRINGS.rangeHint5D
: o.label === "1M" ? UI_STRINGS.rangeHint1Mchip
: o.label === "3M" ? UI_STRINGS.rangeHint3M
: o.label === "6M" ? UI_STRINGS.rangeHint6M
: o.label === "1Y" ? UI_STRINGS.rangeHint1Y
: o.label === "5Y" ? UI_STRINGS.rangeHint5Y
: UI_STRINGS.rangeHintALL;
return (
<button
key={o.label}
type="button"
onClick={() => setRange(o.label)}
className={`group relative flex-1 sm:flex-none min-h-[40px] sm:min-h-0 px-3 py-2 sm:py-1 ${range === o.label ? "bg-accent text-white" : "text-fg-muted hover:text-fg active:bg-surface-sunken"}`}
>
{o.label}
<span className="pointer-events-none absolute left-1/2 top-full z-50 mt-1 hidden w-44 -translate-x-1/2 rounded border border-line bg-surface-raised p-2 text-left text-[11px] font-normal leading-snug text-fg-muted shadow-lg group-hover:block group-focus-visible:block">
{hint}
</span>
</button>
))}
);
})}
</div>
</div>
</div>
{/* Price chart with EMA overlays + Institutional Buy Events */}
{/* Price chart: OHLC candles + EMA 9/21 ribbon (50/200 optional) */}
<div>
<figure>
<p className="text-xs text-fg-muted mb-2">{UI_STRINGS.priceChartCaption}</p>
<div className="h-56 sm:h-72 -mx-1 sm:mx-0">
<p className="text-xs text-fg-muted mb-2">
{UI_STRINGS.priceChartCaption}{" "}
<LastUpdated
label="Bars"
at={candlesFetchedAt}
status={candlesFetchedAt ? "done" : "none"}
/>
</p>
{!displayData.length && (timeframe === '1m' || timeframe === '5m') && (
<p className="text-xs text-fg-muted py-6">{UI_STRINGS.fetchingMinuteBars}</p>
)}
{lastBar && (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2 text-[11px] tabular-nums text-fg-muted">
<span>Last <span className="text-fg font-medium">${lastBar.c.toFixed(2)}</span></span>
{lastBar.ema9 != null && (
<span>EMA 9 <span className="font-medium" style={{ color: SERIES_COLORS.ema9 }}>${lastBar.ema9.toFixed(2)}</span></span>
)}
{lastBar.ema21 != null && (
<span>EMA 21 <span className="font-medium" style={{ color: SERIES_COLORS.ema21 }}>${lastBar.ema21.toFixed(2)}</span></span>
)}
{relationLabel && (
<span className={lastRelation === "above" ? "text-up" : lastRelation === "below" ? "text-down" : ""}>
{relationLabel}
</span>
)}
{rangeModel.levels.up1 != null && (
<span title={UI_STRINGS.ema921RangeHint}>{UI_STRINGS.ema921Up1} <span className="text-up">${rangeModel.levels.up1.toFixed(2)}</span></span>
)}
{rangeModel.levels.up2 != null && (
<span title={UI_STRINGS.ema921RangeHint}>{UI_STRINGS.ema921Up2} <span className="text-up">${rangeModel.levels.up2.toFixed(2)}</span></span>
)}
{rangeModel.levels.down1 != null && (
<span title={UI_STRINGS.ema921RangeHint}>{UI_STRINGS.ema921Down1} <span className="text-down">${rangeModel.levels.down1.toFixed(2)}</span></span>
)}
{rangeModel.levels.down2 != null && (
<span title={UI_STRINGS.ema921RangeHint}>{UI_STRINGS.ema921Down2} <span className="text-down">${rangeModel.levels.down2.toFixed(2)}</span></span>
)}
</div>
)}
<div className="h-64 sm:h-80 -mx-1 sm:mx-0">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={displayData} margin={{ top: 8, right: 4, bottom: 8, left: 0 }}>
<ComposedChart data={displayData} margin={{ top: 16, right: 4, bottom: 8, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={CHART.grid} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART.axis }} interval="preserveStartEnd" />
<XAxis
dataKey="ts"
tick={{ fontSize: 10, fill: CHART.axis }}
interval="preserveStartEnd"
tickFormatter={(ts: string) => formatCandleDate(String(ts), timeframe)}
/>
<YAxis domain={[priceMin, priceMax]} tick={{ fontSize: 10, fill: CHART.axis }} width={48} tickFormatter={(v: number) => `$${v.toFixed(0)}`} />
<YAxis yAxisId="inst" orientation="right" domain={[0, maxInstVol]} hide />
<Tooltip contentStyle={CHART.tooltip}
labelFormatter={(label) => `Date: ${label}`}
formatter={tooltipFormatter as any}
<Tooltip content={<PriceTooltip visible={visibleSeries} />} />
<PriceCandleLayer
data={displayData}
showCandles={visibleSeries.candles}
showRibbon={visibleSeries.ema9 && visibleSeries.ema21}
showRange={visibleSeries.ema9 && visibleSeries.ema21}
levels={rangeModel.levels}
upFromDate={upFromDate}
downFromDate={downFromDate}
upColor={upColor}
downColor={downColor}
/>
{visibleSeries.close && <Line dataKey="c" name="close" stroke={SERIES_COLORS.close} strokeWidth={1.5} dot={false} />}
{visibleSeries.ema9 && <Line type="monotone" dataKey="ema9" stroke={SERIES_COLORS.ema9} strokeWidth={1} dot={false} name="ema9" />}
{visibleSeries.ema21 && <Line type="monotone" dataKey="ema21" stroke={SERIES_COLORS.ema21} strokeWidth={1} dot={false} name="ema21" />}
{visibleSeries.ema50 && <Line type="monotone" dataKey="ema50" stroke={SERIES_COLORS.ema50} strokeWidth={1} dot={false} name="ema50" />}
{visibleSeries.ema200 && <Line type="monotone" dataKey="ema200" stroke={SERIES_COLORS.ema200} strokeWidth={1} dot={false} name="ema200" />}
<Line dataKey="c" name="ohlc" stroke="transparent" strokeWidth={0} dot={false} isAnimationActive={false}
activeDot={{ r: 2.5, fill: CHART.accent }} />
{visibleSeries.ema9 && <Line type="monotone" dataKey="ema9" stroke={SERIES_COLORS.ema9} strokeWidth={1.5} dot={false} name="ema9" isAnimationActive={false} />}
{visibleSeries.ema21 && <Line type="monotone" dataKey="ema21" stroke={SERIES_COLORS.ema21} strokeWidth={1.5} dot={false} name="ema21" isAnimationActive={false} />}
{visibleSeries.ema50 && <Line type="monotone" dataKey="ema50" stroke={SERIES_COLORS.ema50} strokeWidth={1} dot={false} name="ema50" isAnimationActive={false} />}
{visibleSeries.ema200 && <Line type="monotone" dataKey="ema200" stroke={SERIES_COLORS.ema200} strokeWidth={1} dot={false} name="ema200" isAnimationActive={false} />}
{visibleSeries.instBuys && <Bar dataKey="instVol" yAxisId="inst" fill={SERIES_COLORS.instBuys} opacity={0.35} radius={[0, 0, 0, 0]} />}
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Custom toggleable legend */}
<div className="flex flex-wrap gap-2 sm:gap-3 mt-2 justify-center">
{(Object.keys(SERIES_LABELS) as SeriesKey[]).map((key) => (
<button key={key} type="button" onClick={() => setVisibleSeries((prev) => ({ ...prev, [key]: !prev[key] }))}
className="flex items-center gap-1.5 text-xs min-h-[36px] sm:min-h-0 px-2 sm:px-0 rounded-md sm:rounded-none border border-line sm:border-0 transition-opacity active:bg-surface-sunken"
style={{ opacity: visibleSeries[key] ? 1 : 0.4 }}>
<span className={`${key === 'instBuys' ? 'w-3 h-2.5 rounded' : 'w-3 h-0.5 rounded-sm'} inline-block`} style={{ backgroundColor: SERIES_COLORS[key] }} />
<span
className={`${key === 'instBuys' || key === 'candles' ? 'w-3 h-2.5 rounded' : 'w-3 h-0.5 rounded-sm'} inline-block`}
style={{ backgroundColor: SERIES_COLORS[key] }}
/>
{SERIES_LABELS[key]}
</button>
))}
</div>
<figcaption className="text-[11px] text-fg-muted mt-1">
{UI_STRINGS.emaTooltip}
{UI_STRINGS.emaTooltip} {UI_STRINGS.emaRibbonCaption}
</figcaption>
</figure>
</div>
+98 -95
View File
@@ -26,11 +26,12 @@ interface SignalFire {
}
interface ChartPoint {
date: string;
i: number;
dateKey: string;
dateShort: string;
close: number;
bullFires: SignalFire[];
bearFires: SignalFire[];
fires: SignalFire[];
zone: 'entry' | 'exit' | null;
}
interface EntryExitZone {
@@ -39,6 +40,32 @@ interface EntryExitZone {
type: 'entry' | 'exit';
}
export interface ChartZone {
startAsOf: string;
endAsOf: string;
type: 'entry' | 'exit';
}
/** First four weeks from zone start - the follow-through window, not the whole stretch. */
const ONSET_BARS = 20;
function zoneKey(zones: ChartZone[]): string {
return zones.map((z) => `${z.type}:${z.startAsOf}:${z.endAsOf}`).join('|');
}
function candleCutoff(zones: ChartZone[]): string {
const sixMo = new Date();
sixMo.setMonth(sixMo.getMonth() - 6);
let cut = sixMo.toISOString().slice(0, 10);
for (const z of zones) {
const pad = new Date(`${z.startAsOf.slice(0, 10)}T00:00:00Z`);
pad.setUTCDate(pad.getUTCDate() - 21);
const padded = pad.toISOString().slice(0, 10);
if (padded < cut) cut = padded;
}
return cut;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -47,17 +74,21 @@ function toDateKey(ts: string): string {
return ts.slice(0, 10);
}
function formatDate(ts: string): string {
function formatTick(dateKey: string, spanYears: boolean): string {
try {
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return new Date(`${dateKey}T12:00:00Z`).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
...(spanYears ? { year: '2-digit' } : {}),
});
} catch {
return ts.slice(5, 10);
return dateKey.slice(5, 10);
}
}
function formatDateFull(ts: string): string {
try {
return new Date(ts).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
return new Date(`${toDateKey(ts)}T12:00:00Z`).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
} catch {
return ts.slice(0, 10);
}
@@ -82,20 +113,23 @@ const BODY_LABEL: Record<string, string> = {
function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: ChartPoint }> }) {
if (!active || !payload?.length) return null;
const point = payload[0].payload;
const allFires = [...point.bullFires, ...point.bearFires];
// Deduplicate by slot name (a slot can fire multiple times on the same day)
const seen = new Set<string>();
const uniqueFires = allFires.filter((f) => {
const uniqueFires = point.fires.filter((f) => {
if (seen.has(f.slotName)) return false;
seen.add(f.slotName);
return true;
});
return (
<div className="rounded border border-line bg-surface-raised px-3 py-2 text-xs shadow-lg">
<div className="font-medium text-fg mb-1">{formatDateFull(point.date)}</div>
<div className="font-medium text-fg mb-1">{formatDateFull(point.dateKey)}</div>
<div className="text-fg-muted">
Close: <span className="text-fg font-medium">${point.close.toFixed(2)}</span>
</div>
{point.zone && (
<div className={`mt-1 ${point.zone === 'entry' ? 'text-green-400' : 'text-red-400'}`}>
{point.zone === 'entry' ? 'Entry zone' : 'Exit zone'}
</div>
)}
{uniqueFires.length > 0 && (
<div className="mt-1.5 space-y-0.5 border-t border-line/50 pt-1.5">
{uniqueFires.map((f, i) => (
@@ -111,72 +145,43 @@ function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{
);
}
// ---------------------------------------------------------------------------
// Custom dot renderer - renders colored markers for signal fires
// ---------------------------------------------------------------------------
function SignalDot(props: { cx?: number; cy?: number; payload?: ChartPoint; index?: number }) {
const { cx, cy, payload } = props;
if (cx == null || cy == null || !payload) return null;
const allFires = [...payload.bullFires, ...payload.bearFires];
if (allFires.length === 0) return null;
// Stack multiple markers vertically if there are several signals
const markers = allFires.slice(0, 4); // cap at 4 visible markers
return (
<g>
{markers.map((f, i) => (
<circle
key={i}
cx={cx}
cy={cy - (i * 7)}
r={3.5}
fill={BODY_COLOR[f.body]}
fillOpacity={0.9}
stroke={BODY_COLOR[f.body]}
strokeWidth={1}
strokeOpacity={0.4}
/>
))}
</g>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface ConfluenceChartProps {
symbol: string;
zones?: ChartZone[];
}
export function ConfluenceChart({ symbol }: ConfluenceChartProps) {
export function ConfluenceChart({ symbol, zones: overlayZones = [] }: ConfluenceChartProps) {
const [candles, setCandles] = useState<PriceCandle[]>([]);
const [fires, setFires] = useState<SignalFire[]>([]);
const [loading, setLoading] = useState(true);
const overlayKey = zoneKey(overlayZones);
useEffect(() => {
let cancelled = false;
setLoading(true);
const cutoffStr = candleCutoff(overlayZones);
Promise.all([
api.market.candles(symbol, '1d'),
api.confluence.signalHistory(symbol),
]).then(([candleData, fireData]) => {
if (cancelled) return;
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - 6);
const cutoffStr = cutoff.toISOString();
setCandles(candleData.filter((c) => c.ts >= cutoffStr));
setFires(fireData.fires);
setCandles(candleData.filter((c) => toDateKey(c.ts) >= cutoffStr));
setFires(fireData.fires.filter((f) => toDateKey(f.firedAt) >= cutoffStr));
setLoading(false);
}).catch(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [symbol]);
// overlayKey stands in for overlayZones so a new array identity does not refetch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [symbol, overlayKey]);
const { chartData, zones } = useMemo(() => {
if (!candles.length) return { chartData: [], zones: [] };
@@ -189,10 +194,9 @@ export function ConfluenceChart({ symbol }: ConfluenceChartProps) {
firesByDate.set(key, arr);
}
const data: ChartPoint[] = candles.map((c) => {
const data: ChartPoint[] = candles.map((c, i) => {
const key = toDateKey(c.ts);
const dayFires = firesByDate.get(key) ?? [];
// Deduplicate by slot name per day (a slot can fire multiple times)
const seenSlots = new Set<string>();
const uniqueDayFires = dayFires.filter((f) => {
if (seenSlots.has(f.slotId)) return false;
@@ -200,35 +204,37 @@ export function ConfluenceChart({ symbol }: ConfluenceChartProps) {
return true;
});
return {
date: c.ts,
dateShort: formatDate(c.ts),
i,
dateKey: key,
dateShort: formatTick(key, false),
close: c.c,
bullFires: uniqueDayFires.filter((f) => f.body === 'bull'),
bearFires: uniqueDayFires.filter((f) => f.body === 'bear' || f.body === 'exit'),
fires: uniqueDayFires,
zone: null,
};
});
// Detect entry/exit zones: 2+ signals of same direction within 5 trading days
const windowSize = 5;
const indexByDate = new Map<string, number>();
data.forEach((p, i) => indexByDate.set(p.dateKey, i));
const rawZones: EntryExitZone[] = [];
for (let i = 0; i < data.length; i++) {
const windowEnd = Math.min(i + windowSize, data.length);
let bullCount = 0;
let bearCount = 0;
for (let j = i; j < windowEnd; j++) {
bullCount += data[j].bullFires.length;
bearCount += data[j].bearFires.length;
}
if (bullCount >= 2 && !rawZones.some((z) => z.type === 'entry' && i >= z.startIdx && i <= z.endIdx)) {
rawZones.push({ startIdx: i, endIdx: Math.min(i + windowSize - 1, data.length - 1), type: 'entry' });
}
if (bearCount >= 2 && !rawZones.some((z) => z.type === 'exit' && i >= z.startIdx && i <= z.endIdx)) {
rawZones.push({ startIdx: i, endIdx: Math.min(i + windowSize - 1, data.length - 1), type: 'exit' });
for (const z of overlayZones) {
const startIdx = indexByDate.get(toDateKey(z.startAsOf));
// Onset is the event. If it is off-screen, do not flood the visible range.
if (startIdx == null) continue;
const naturalEnd = indexByDate.get(toDateKey(z.endAsOf)) ?? data.length - 1;
const endIdx = Math.min(naturalEnd, startIdx + ONSET_BARS - 1, data.length - 1);
if (endIdx < startIdx) continue;
rawZones.push({ startIdx, endIdx, type: z.type });
}
for (const z of rawZones) {
for (let i = z.startIdx; i <= z.endIdx; i++) {
if (data[i].zone === null) data[i].zone = z.type;
}
}
return { chartData: data, zones: rawZones };
}, [candles, fires]);
}, [candles, fires, overlayZones]);
if (loading) {
return (
@@ -248,25 +254,14 @@ export function ConfluenceChart({ symbol }: ConfluenceChartProps) {
return (
<div className="space-y-3">
{/* Legend */}
<div className="flex flex-wrap items-center gap-3 text-[10px] text-fg-muted">
<span className="font-medium text-fg">Signal markers:</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-green-500" /> Bull
<span className="inline-block w-3 h-2 rounded-sm bg-green-500/25 border border-green-500/50" /> Entry zone
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-red-500" /> Bear
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-amber-500" /> Exit
</span>
<span className="mx-1">|</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-2 rounded-sm bg-green-500/20 border border-green-500/40" /> Entry zone
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-2 rounded-sm bg-red-500/20 border border-red-500/40" /> Exit zone
<span className="inline-block w-3 h-2 rounded-sm bg-red-500/25 border border-red-500/50" /> Exit zone
</span>
<span>Bands mark the first four weeks of each window. Hover a session for the slots that were lit that day.</span>
</div>
{/* Chart */}
@@ -280,12 +275,21 @@ export function ConfluenceChart({ symbol }: ConfluenceChartProps) {
</defs>
<CartesianGrid stroke={CHART.grid} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="dateShort"
dataKey="i"
type="number"
domain={[0, Math.max(0, chartData.length - 1)]}
tick={{ fontSize: 10, fill: CHART.axis }}
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
allowDecimals={false}
minTickGap={40}
tickFormatter={(idx: number) => {
const first = chartData[0]?.dateKey;
const last = chartData[chartData.length - 1]?.dateKey;
const spanYears = !!first && !!last && first.slice(0, 4) !== last.slice(0, 4);
const key = chartData[idx]?.dateKey;
return key ? formatTick(key, spanYears) : '';
}}
/>
<YAxis
tick={{ fontSize: 10, fill: CHART.axis }}
@@ -301,24 +305,23 @@ export function ConfluenceChart({ symbol }: ConfluenceChartProps) {
{zones.map((z, i) => (
<ReferenceArea
key={i}
x1={chartData[z.startIdx]?.dateShort}
x2={chartData[z.endIdx]?.dateShort}
x1={z.startIdx}
x2={z.endIdx}
fill={z.type === 'entry' ? '#22c55e' : '#ef4444'}
fillOpacity={0.08}
fillOpacity={0.28}
stroke={z.type === 'entry' ? '#22c55e' : '#ef4444'}
strokeOpacity={0.2}
strokeDasharray="3 3"
strokeOpacity={0.7}
strokeWidth={1}
/>
))}
{/* Price area with signal markers */}
<Area
type="monotone"
dataKey="close"
stroke={CHART.accent}
strokeWidth={1.5}
fill="url(#confluencePriceGradient)"
dot={<SignalDot />}
dot={false}
activeDot={{ r: 3, fill: CHART.accent }}
/>
</AreaChart>
+207 -47
View File
@@ -1,9 +1,8 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
import { api } from '@/lib/trpc';
import { useActiveSymbol } from '@/stores/active-symbol-store';
import { CollapsibleSection } from '@/components/CollapsibleSection';
import { SymbolAutocomplete } from '@/components/SymbolAutocomplete';
import { ConfluenceChart } from '@/components/ConfluenceChart';
// ---------------------------------------------------------------------------
@@ -40,35 +39,72 @@ const BODY_BADGE: Record<string, string> = {
exit: 'bg-amber-600/15 text-amber-400',
};
type ZoneRow = {
kind: 'entry' | 'exit';
startAsOf: string;
endAsOf: string;
open: boolean;
qualityAtStart: string;
netEvidenceAtStart: number;
firedSlotIds: string[];
forwardReturn: number | null;
verdict: 'confirmed' | 'false_alarm' | 'open';
};
type ZoneRuleCard = {
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;
};
function formatPct(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '-';
return `${(v * 100).toFixed(0)}%`;
}
function formatRet(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return '-';
const sign = v > 0 ? '+' : '';
return `${sign}${(v * 100).toFixed(1)}%`;
}
function windowLabel(z: ZoneRow): string {
const end = z.open ? `${z.endAsOf} (open)` : z.endAsOf;
return z.startAsOf === z.endAsOf && !z.open ? z.startAsOf : `${z.startAsOf} - ${end}`;
}
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
export function ConfluencePanel() {
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
const setActiveSymbol = useActiveSymbol((s) => s.setActive);
const [symbol, setSymbol] = useState(activeSymbol);
const [searchQuery, setSearchQuery] = useState(activeSymbol);
const symbol = useActiveSymbol((s) => s.activeSymbol);
const [selectedRackId, setSelectedRackId] = useState<string | null>(null);
const [evaluation, setEvaluation] = useState<Record<string, unknown> | null>(null);
const [scorecard, setScorecard] = useState<{ slots: Array<Record<string, unknown>>; note: string } | null>(null);
const [recent, setRecent] = useState<{
entries: ZoneRow[];
exits: ZoneRow[];
entryRule: ZoneRuleCard;
exitRule: ZoneRuleCard;
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 };
} | null>(null);
const pickSymbol = useCallback((sym: string) => {
setSymbol(sym);
setSearchQuery(sym);
setActiveSymbol(sym);
// Clear stale data immediately so old symbol never flashes
// Clear stale rows as soon as the shell ticker changes so the previous
// symbol never flashes under the new one.
useEffect(() => {
setEvaluation(null);
setScorecard(null);
}, [setActiveSymbol]);
// Sync searchQuery when activeSymbol changes externally (e.g., from another panel)
useEffect(() => {
setSearchQuery(activeSymbol);
}, [activeSymbol]);
// Sync symbol state when activeSymbol changes externally
useEffect(() => {
setSymbol(activeSymbol);
}, [activeSymbol]);
setRecent(null);
}, [symbol]);
// Racks
const [racks, setRacks] = useState<{ system: Array<{ id: string; name: string }>; user: Array<{ id: string; name: string }> }>({ system: [], user: [] });
@@ -76,8 +112,6 @@ export function ConfluencePanel() {
api.confluence.racks().then(setRacks).catch(() => {});
}, []);
// Evaluation
const [evaluation, setEvaluation] = useState<Record<string, unknown> | null>(null);
const rackId = selectedRackId ?? racks.system[0]?.id ?? null;
useEffect(() => {
if (!symbol) return;
@@ -88,8 +122,50 @@ export function ConfluencePanel() {
return () => { cancelled = true; };
}, [symbol, rackId]);
// Scorecard
const [scorecard, setScorecard] = useState<{ slots: Array<Record<string, unknown>>; note: string } | null>(null);
useEffect(() => {
if (!symbol) return;
let cancelled = false;
const load = () => {
api.confluence.recentZones(symbol, { rackId: rackId ?? undefined, entries: 3, exits: 3 })
.then((r) => {
if (cancelled) return;
setRecent({
entries: r.entries,
exits: r.exits,
entryRule: r.entryRule,
exitRule: r.exitRule,
coverage: r.coverage,
symbolScore: r.symbolScore,
learningLedger: r.learningLedger,
});
})
.catch(() => { if (!cancelled) setRecent(null); });
};
load();
const timer = window.setInterval(() => {
if (cancelled) return;
api.confluence.recentZones(symbol, { rackId: rackId ?? undefined, entries: 3, exits: 3 })
.then((r) => {
if (cancelled) return;
setRecent({
entries: r.entries,
exits: r.exits,
entryRule: r.entryRule,
exitRule: r.exitRule,
symbolScore: r.symbolScore,
learningLedger: r.learningLedger,
coverage: r.coverage,
});
if (r.coverage.replayComplete) window.clearInterval(timer);
})
.catch(() => {});
}, 8000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [symbol, rackId]);
useEffect(() => {
if (!symbol) return;
let cancelled = false;
@@ -131,27 +207,13 @@ export function ConfluencePanel() {
{/* Explanatory text */}
<div className="text-xs text-fg-muted leading-relaxed">
Signal Confluence combines technical, seasonal, and valuation signals into a single
directional read. Each slot fires (green dot) when its condition is met, or stays
dormant. The overall quality grade reflects how many slots fired and in which direction.
Use the rack selector to switch between different signal subsets.
directional read. The timeline shows the last few entry and exit zones. Hover a session
to see which slots were lit that day. The quality grade reflects how many slots fired
and in which direction. Use the rack selector to switch between different signal subsets.
</div>
{/* Header */}
{/* Rack selector — ticker lives in the shell header */}
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="block text-xs text-fg-muted mb-1">Symbol</label>
<div className="relative w-48">
<SymbolAutocomplete
value={searchQuery}
onChange={setSearchQuery}
onPick={pickSymbol}
onSubmitRaw={(raw) => pickSymbol(raw.toUpperCase())}
placeholder="Search symbol…"
ariaLabel="Select symbol for signal confluence"
inputClassName="w-full bg-surface-sunken border border-line rounded-lg px-3 py-1.5 text-xs text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
/>
</div>
</div>
<div>
<label className="block text-xs text-fg-muted mb-1">Rack</label>
<select
@@ -205,8 +267,104 @@ export function ConfluencePanel() {
{/* Signal Timeline chart */}
{symbol && (
<CollapsibleSection title="Signal Timeline" defaultOpen>
<ConfluenceChart key={symbol} symbol={symbol} />
<CollapsibleSection title="Entry and exit zones" defaultOpen>
{recent && (
<div className="mb-3 space-y-2 text-xs text-fg-muted leading-relaxed">
<div className="rounded border border-fg-muted/20 bg-bg-secondary px-3 py-2 space-y-1">
<div>
<span className="text-fg font-medium">Entry window rule</span>
{recent.entryRule.source === 'baseline' && (
<span className="ml-2 text-[10px] uppercase tracking-wide text-amber-400">sparse / baseline</span>
)}
<span className="text-fg-muted"> - {recent.entryRule.label}</span>
</div>
<div>
<span className="text-fg font-medium">Exit window rule</span>
{recent.exitRule.source === 'baseline' && (
<span className="ml-2 text-[10px] uppercase tracking-wide text-amber-400">sparse / baseline</span>
)}
<span className="text-fg-muted"> - {recent.exitRule.label}</span>
</div>
<div>
Sector-checked prior (held-out year): entry {formatPct(recent.entryRule.validate?.hitRate)} vs {formatPct(recent.entryRule.validate?.baselineHitRate)} baseline
{' '}/ exit {formatPct(recent.exitRule.validate?.hitRate)} vs {formatPct(recent.exitRule.validate?.baselineHitRate)} baseline.
{' '}{recent.entryRule.sampleCaveat}
</div>
<div>
On {symbol}, this same prior followed through in {formatPct(recent.symbolScore.entry.hitRate)} of {recent.symbolScore.entry.resolved} resolved entry windows
{' '}({recent.symbolScore.entry.confirmed} confirmed).
{recent.entryRule.validate?.hitRate != null && recent.symbolScore.entry.hitRate != null && recent.symbolScore.entry.hitRate + 0.10 < recent.entryRule.validate.hitRate
? ' This name has been weaker than the sector check under the same prior.'
: ''}
</div>
{!recent.learningLedger.ready && (
<div>
Learning ledger {recent.learningLedger.filled}/{recent.learningLedger.total} sector ETFs + SPY. The prior is not sector-checked yet.
</div>
)}
</div>
{!recent.coverage.replayComplete && (
<div>
Replaying cached history for {symbol} ({recent.coverage.evaluatedDays}/{recent.coverage.lookbackDays} sessions). Windows will fill in.
</div>
)}
{recent.entries.some((z) => z.open) && (
<div>
Latest entry window started {recent.entries.find((z) => z.open)!.startAsOf}.
</div>
)}
{recent.exits.length === 0 && (
<div>
No exit windows. The current exit prior needs a {recent.exitRule.label.toLowerCase()}.
{recent.symbolScore.exit.zones === 0
? ' This name never reached that picture in the replayed history (mostly mixed or bullish), so there is nothing to shade in red.'
: ''}
</div>
)}
</div>
)}
<ConfluenceChart
key={symbol}
symbol={symbol}
zones={[
...(recent?.entries ?? []).map((z) => ({ startAsOf: z.startAsOf, endAsOf: z.endAsOf, type: 'entry' as const })),
...(recent?.exits ?? []).map((z) => ({ startAsOf: z.startAsOf, endAsOf: z.endAsOf, type: 'exit' as const })),
]}
/>
{recent && (recent.entries.length > 0 || recent.exits.length > 0) && (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-fg-muted border-b border-fg-muted/20">
<th className="text-left py-1">Side</th>
<th className="text-left py-1">Window</th>
<th className="text-left py-1">Picture at start</th>
<th className="text-left py-1">Lit slots</th>
<th className="text-right py-1">4w move</th>
<th className="text-right py-1">Follow-through</th>
</tr>
</thead>
<tbody>
{[...recent.entries, ...recent.exits]
.sort((a, b) => b.startAsOf.localeCompare(a.startAsOf))
.map((z) => (
<tr key={`${z.kind}-${z.startAsOf}`} className="border-b border-fg-muted/10">
<td className="py-1 capitalize">{z.kind}</td>
<td className="py-1">{windowLabel(z)}</td>
<td className="py-1">{QUALITY_LABEL[z.qualityAtStart as Quality] ?? z.qualityAtStart}</td>
<td className="py-1 text-fg-muted">
{z.firedSlotIds.slice(0, 3).map((id) => slotMeta.get(id)?.name ?? id).join(', ') || '-'}
</td>
<td className="text-right py-1">{formatRet(z.forwardReturn)}</td>
<td className="text-right py-1">
{z.verdict === 'confirmed' ? 'confirmed' : z.verdict === 'false_alarm' ? 'false alarm' : 'open'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CollapsibleSection>
)}
@@ -237,7 +395,9 @@ export function ConfluencePanel() {
{/* Scorecard */}
{scorecard && scorecard.slots.length > 0 && (
<CollapsibleSection title="Reliability Scorecard">
<p className="text-xs text-fg-muted mb-2">{scorecard.note}</p>
<p className="text-xs text-fg-muted mb-2">
{scorecard.note} These weights now change how much each slot contributes to the current picture.
</p>
<table className="w-full text-xs">
<thead>
<tr className="text-fg-muted border-b border-fg-muted/20">
+10 -19
View File
@@ -3,7 +3,6 @@ import { useState, useEffect, useCallback } from 'react';
import { api } from '@/lib/trpc';
import { useActiveSymbol } from '@/stores/active-symbol-store';
import { CollapsibleSection } from '@/components/CollapsibleSection';
import { SymbolAutocomplete } from '@/components/SymbolAutocomplete';
// ---------------------------------------------------------------------------
// Types (subset of server types for display)
@@ -83,9 +82,8 @@ const price = (v: number | null | undefined): string =>
// ---------------------------------------------------------------------------
export function CorridorPanel() {
const activeSymbol = useActiveSymbol((s) => s.activeSymbol);
const symbol = useActiveSymbol((s) => s.activeSymbol);
const setActiveSymbol = useActiveSymbol((s) => s.setActive);
const [symbol, setSymbol] = useState(activeSymbol);
const [data, setData] = useState<CorridorData | null>(null);
const [watchlist, setWatchlist] = useState<string[]>([]);
const [addInput, setAddInput] = useState('');
@@ -100,7 +98,10 @@ export function CorridorPanel() {
.catch(() => setData(null));
}, []);
useEffect(() => { loadCorridor(symbol); }, [symbol, loadCorridor]);
useEffect(() => {
setData(null);
loadCorridor(symbol);
}, [symbol, loadCorridor]);
useEffect(() => {
api.confluence.corridorWatchlist()
@@ -115,7 +116,6 @@ export function CorridorPanel() {
}, []);
const pickSymbol = useCallback((sym: string) => {
setSymbol(sym);
setActiveSymbol(sym);
}, [setActiveSymbol]);
@@ -174,20 +174,8 @@ export function CorridorPanel() {
Fair value is derived from the median P/E over the lookback window applied to trailing EPS.
</div>
{/* Header: symbol + watchlist */}
{/* Watchlist — ticker lives in the shell header; chips switch the shared symbol */}
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="block text-xs text-fg-muted mb-1">Symbol</label>
<div className="relative w-48">
<SymbolAutocomplete
onPick={pickSymbol}
onSubmitRaw={(raw) => pickSymbol(raw.toUpperCase())}
placeholder="Search symbol…"
ariaLabel="Select symbol for corridor"
inputClassName="w-full bg-surface-sunken border border-line rounded-lg px-3 py-1.5 text-xs text-fg placeholder:text-fg-muted focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent"
/>
</div>
</div>
<div>
<label className="block text-xs text-fg-muted mb-1">Watchlist</label>
<div className="flex items-center gap-2">
@@ -211,7 +199,10 @@ export function CorridorPanel() {
<div className="mt-2 flex flex-wrap gap-1.5">
{watchlist.map((s) => (
<span key={s} className="inline-flex items-center gap-1 rounded border border-fg-muted/20 bg-bg-secondary px-2 py-0.5 text-xs text-fg">
<button onClick={() => pickSymbol(s)} className="hover:text-green-400">{s}</button>
<button
onClick={() => pickSymbol(s)}
className={s === symbol ? 'text-green-400 font-medium' : 'hover:text-green-400'}
>{s}</button>
<button onClick={() => removeFromWatchlist(s)} className="text-fg-muted hover:text-red-400" aria-label={`Remove ${s}`}>×</button>
</span>
))}
+7 -1
View File
@@ -39,11 +39,17 @@ export function FundHoldingsStrip({ symbol }: { symbol: string | null }) {
{data.tracked.length > 0 && (
<ul className="space-y-1.5">
{data.tracked.map((t) => (
<li key={t.fundId} className="flex items-center gap-2 text-sm">
<li
key={`${t.fundId}:${t.notes === "call" || t.notes === "put" ? t.notes : "eq"}`}
className="flex items-center gap-2 text-sm"
>
<span className="font-medium text-fg">{t.fundName}</span>
{t.weightPct != null && (
<span className="text-xs text-fg-muted tnum">{t.weightPct.toFixed(1)}% of book</span>
)}
{(t.notes === "call" || t.notes === "put") && (
<Badge tone="warn">{t.notes}</Badge>
)}
<Badge tone={t.source === "13f" ? "neutral" : "warn"}>
{t.source === "13f" ? "SEC 13F" : t.source}
</Badge>
+22 -29
View File
@@ -4,12 +4,7 @@ import Link from "next/link";
import { Bell } from "lucide-react";
import { api, type AlertEventRow } from "@/lib/trpc";
import { useVisibilityAwarePoll } from "@/lib/useVisibilityAwarePoll";
const SEVERITY_COLORS: Record<string, string> = {
info: "text-blue-400 bg-blue-950/30 border-blue-500/30",
warning: "text-amber-400 bg-amber-950/30 border-amber-500/30",
critical: "text-red-400 bg-red-950/30 border-red-500/30",
};
import { AlertEventCard } from "@/components/AlertEventCard";
const POLL_INTERVAL = 30_000;
@@ -17,6 +12,7 @@ export function NotificationBell() {
const [open, setOpen] = useState(false);
const [alerts, setAlerts] = useState<AlertEventRow[]>([]);
const [unackedCount, setUnackedCount] = useState(0);
const [expandedId, setExpandedId] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
const fetchAlerts = useCallback(async () => {
@@ -53,6 +49,7 @@ export function NotificationBell() {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setExpandedId(null);
}
};
if (open) {
@@ -63,7 +60,10 @@ export function NotificationBell() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
if (e.key === "Escape") {
setOpen(false);
setExpandedId(null);
}
};
if (open) {
window.addEventListener("keydown", handler);
@@ -77,6 +77,7 @@ export function NotificationBell() {
prev.map((a) => (a.id === id ? { ...a, acknowledged: true } : a)),
);
setUnackedCount((c) => Math.max(0, c - 1));
setExpandedId((current) => (current === id ? null : current));
window.dispatchEvent(new CustomEvent("alert-dismissed"));
};
@@ -85,7 +86,12 @@ export function NotificationBell() {
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
onClick={() => {
setOpen((wasOpen) => {
if (wasOpen) setExpandedId(null);
return !wasOpen;
});
}}
aria-label={`Notifications${unackedCount > 0 ? ` (${unackedCount} unread)` : ""}`}
className="relative flex items-center justify-center min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0 rounded-lg p-1.5 text-fg-muted hover:text-fg hover:bg-surface-sunken transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
@@ -117,27 +123,14 @@ export function NotificationBell() {
) : (
<div className="divide-y divide-line">
{unackedAlerts.map((a) => (
<div key={a.id} className="px-4 py-3 hover:bg-surface-sunken transition-colors">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 mb-0.5 flex-wrap">
<span className={`rounded border px-1 py-0.5 text-[9px] font-medium ${SEVERITY_COLORS[a.severity] ?? ""}`}>{a.severity}</span>
<span className="text-xs font-medium text-fg truncate">{a.title}</span>
{a.symbol && <span className="rounded bg-surface-dark px-1 py-0.5 text-[9px] text-fg-faint">{a.symbol}</span>}
</div>
<p className="text-xs text-fg-faint line-clamp-2">{a.description}</p>
<p className="text-[10px] text-fg-faint/50 mt-0.5">
{new Date(a.createdAt).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}
</p>
</div>
<button
onClick={() => dismiss(a.id)}
className="shrink-0 min-h-[44px] sm:min-h-0 rounded-md border border-line px-3 py-1.5 text-xs text-fg-faint hover:text-fg hover:bg-surface-dark transition-colors"
>
Dismiss
</button>
</div>
</div>
<AlertEventCard
key={a.id}
alert={a}
compact
expanded={expandedId === a.id}
onToggle={() => setExpandedId((id) => (id === a.id ? null : a.id))}
onDismiss={() => void dismiss(a.id)}
/>
))}
</div>
)}
+16 -6
View File
@@ -5,6 +5,7 @@ import { api, type Snapshot } from "@/lib/trpc";
import { UI_STRINGS } from "@/lib/strings";
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
import { chart as CHART } from "@/lib/chart-theme";
import { LastUpdated } from "@/components/shared/LastUpdated";
// M1 Symbol Overview (DESIGN.md §4). Reads market.snapshot from cache; polls so the
// stale-while-revalidate background refresh shows up once the cache fills. P7: the day-change
@@ -113,12 +114,21 @@ export function OverviewPanel() {
<h2 className="text-base font-semibold text-fg">{symbol}</h2>
<p className="text-xs text-fg-muted">{sector?.name ?? sector?.sector ?? ""}</p>
</div>
{snap && (snap.stale.quote || snap.stale.candles || snap.stale.sector) && (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-warn border border-amber-800 rounded px-2 py-0.5">
<span aria-hidden="true">⚠</span>
{UI_STRINGS.staleBadge}
</span>
)}
<div className="flex flex-col items-end gap-1">
{snap && (
<LastUpdated
label="Quote"
at={snap.observed?.quote ?? null}
status={snap.observed?.quote ? "done" : "none"}
/>
)}
{snap && (snap.stale.quote || snap.stale.candles || snap.stale.sector) && (
<span className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider text-warn border border-amber-800 rounded px-2 py-0.5">
<span aria-hidden="true">⚠</span>
{UI_STRINGS.staleBadge}
</span>
)}
</div>
</header>
{loading && !snap ? (
+22 -1
View File
@@ -3,6 +3,7 @@ import { useEffect, useState, useMemo, useCallback } from "react";
import { useActiveSymbol } from "@/stores/active-symbol-store";
import { api } from "@/lib/trpc";
import type { XPost, XFedFeed } from "@/lib/trpc";
import { LastUpdated } from "@/components/shared/LastUpdated";
// Social feed panel: combines cashtag posts about $SYMBOL with tracked-account timelines.
// ADR-0007 caveat banner included — crowd sentiment is not edge. Educational display only.
@@ -70,8 +71,28 @@ export function SocialFeedPanel() {
<div>
<h2 className="text-base font-semibold text-fg">X feed: {symbol}</h2>
<p className="text-xs text-fg-muted">
Tracked accounts mentioning ${symbol} — page {pageIdx + 1}
Tracked accounts mentioning ${symbol} - page {pageIdx + 1}
</p>
{feed && (
<div className="mt-0.5">
<LastUpdated
label="X pull"
at={feed.lastPullAt ?? null}
status={
(feed.queuedHandles ?? 0) > 0 && !feed.lastPullAt
? "pending"
: feed.lastPullAt
? "done"
: "none"
}
/>
{(feed.queuedHandles ?? 0) > 0 && feed.lastPullAt && (
<span className="ml-2 text-[11px] text-fg-muted">
{feed.queuedHandles} still queued
</span>
)}
</div>
)}
</div>
</header>
+20
View File
@@ -10,6 +10,8 @@ import {
MobileListsButton,
MobileWatchlistSheet,
} from "@/components/MobileWatchlistSheet";
import { LastUpdated } from "@/components/shared/LastUpdated";
import { quoteStampTone } from "@/lib/lastUpdatedCopy";
/**
* Persistent top bar showing the active symbol, price, change, and search.
@@ -106,6 +108,15 @@ export function SymbolHeader() {
{changeLabel && (
<span className={`font-mono ${up ? "text-up" : "text-down"}`}>{changeLabel}</span>
)}
{snap?.observed?.quote && (
<LastUpdated
at={snap.observed.quote}
status="done"
className={`text-[10px] tabular-nums ${
quoteStampTone(snap.observed.quote) === "stale" ? "text-[#fbbf24]" : "text-fg-faint"
}`}
/>
)}
</div>
{/* Mobile: compact ticker */}
@@ -130,6 +141,15 @@ export function SymbolHeader() {
{changePctLabel}
</span>
)}
{snap?.observed?.quote && (
<LastUpdated
at={snap.observed.quote}
status="done"
className={`text-[9px] tabular-nums leading-tight ${
quoteStampTone(snap.observed.quote) === "stale" ? "text-[#fbbf24]" : "text-fg-faint"
}`}
/>
)}
</div>
{/* Desktop search */}
@@ -0,0 +1,188 @@
"use client";
import { usePlotArea, useXAxisScale, useYAxisScale } from "recharts";
import { layoutCandle, ribbonPath, ribbonRuns } from "@/lib/priceChartGeometry";
import type { Ema921Cross, Ema921Levels, Ema921Sentiment } from "@/lib/ema921Range";
import { UI_STRINGS } from "@/lib/strings";
export interface PricePanePoint {
date: string;
ts?: string;
o: number;
h: number;
l: number;
c: number;
ema9?: number;
ema21?: number;
sentiment?: Ema921Sentiment | null;
cross?: Ema921Cross | null;
}
interface PriceCandleLayerProps {
data: PricePanePoint[];
showCandles: boolean;
showRibbon: boolean;
showRange: boolean;
levels: Ema921Levels;
upFromDate: string | null;
downFromDate: string | null;
upColor: string;
downColor: string;
}
const BETWEEN = "#6b7280";
const CROSS_UP = "#a78bfa";
const CROSS_DOWN = "#fb923c";
function sentimentFill(s: Ema921Sentiment | null | undefined, up: string, down: string): string {
if (s === "above") return up;
if (s === "below") return down;
return BETWEEN;
}
export function PriceCandleLayer({
data,
showCandles,
showRibbon,
showRange,
levels,
upFromDate,
downFromDate,
upColor,
downColor,
}: PriceCandleLayerProps) {
const xScale = useXAxisScale();
const yScale = useYAxisScale();
const plot = usePlotArea();
if (!xScale || !yScale || data.length === 0) return null;
const yOf = (price: number) => yScale(price);
const xOf = (d: PricePanePoint) => xScale(d.ts ?? d.date, { position: "middle" });
const right = plot ? plot.x + plot.width : undefined;
const firstX = xOf(data[0]!);
const nextX = data.length > 1 ? xOf(data[1]!) : undefined;
const slot = firstX != null && nextX != null ? Math.abs(nextX - firstX) : 8;
const bodyW = Math.max(1, Math.min(14, slot * 0.62));
const ribbonSamples = showRibbon
? data.flatMap((d) => {
const x = xOf(d);
if (x == null || d.ema9 == null || d.ema21 == null) return [];
return [{ x, ema9: d.ema9, ema21: d.ema21, sentiment: d.sentiment ?? null }];
})
: [];
// Reuse 9-vs-21 geometry for the path; color each run from the first sample's sentiment.
const runs = showRibbon
? ribbonRuns(ribbonSamples).map((run, i) => ({
...run,
fill: sentimentFill(ribbonSamples.find((s) => s.x === run.samples[0]?.x)?.sentiment, upColor, downColor),
i,
}))
: [];
const levelLines: Array<{ y: number; x1: number; color: string; label: string }> = [];
if (showRange && right != null) {
const add = (price: number | null, from: string | null, color: string, label: string) => {
if (price == null || from == null) return;
const x1 = xScale(from, { position: "middle" });
const y = yOf(price);
if (x1 == null || y == null || !Number.isFinite(y)) return;
levelLines.push({ y, x1, color, label: `${label} $${price.toFixed(2)}` });
};
add(levels.up1, upFromDate, upColor, UI_STRINGS.ema921Up1);
add(levels.up2, upFromDate, upColor, UI_STRINGS.ema921Up2);
add(levels.down1, downFromDate, downColor, UI_STRINGS.ema921Down1);
add(levels.down2, downFromDate, downColor, UI_STRINGS.ema921Down2);
}
return (
<g className="price-pane-overlays" pointerEvents="none">
{runs.map((run) => {
const d = ribbonPath(run.samples, (p) => yOf(p) ?? NaN);
if (!d) return null;
return (
<path
key={`ribbon-${run.i}`}
d={d}
fill={run.fill}
fillOpacity={0.18}
stroke="none"
/>
);
})}
{showCandles &&
data.map((bar) => {
const cx = xOf(bar);
if (cx == null) return null;
const layout = layoutCandle(bar, cx, (p) => yOf(p) ?? NaN, bodyW);
if (!layout) return null;
const color = layout.up ? upColor : downColor;
return (
<g key={bar.ts ?? bar.date}>
<line
x1={layout.cx}
y1={layout.wickTop}
x2={layout.cx}
y2={layout.wickBottom}
stroke={color}
strokeWidth={1}
/>
<rect
x={layout.bodyX}
y={layout.bodyY}
width={layout.bodyW}
height={layout.bodyH}
fill={layout.up ? "var(--color-surface-raised)" : color}
stroke={color}
strokeWidth={1}
/>
</g>
);
})}
{showRange &&
data.map((bar) => {
if (!bar.cross) return null;
const cx = xOf(bar);
const yTip = yOf(bar.cross === "up" ? bar.l : bar.h);
if (cx == null || yTip == null) return null;
const dir = bar.cross === "up" ? 1 : -1;
const fill = bar.cross === "up" ? CROSS_UP : CROSS_DOWN;
const tip = yTip + dir * 4;
return (
<polygon
key={`cross-${bar.ts ?? bar.date}`}
points={`${cx},${tip} ${cx - 4},${tip + dir * 7} ${cx + 4},${tip + dir * 7}`}
fill={fill}
/>
);
})}
{levelLines.map((ln, i) => {
const labelH = 12;
const labelAbove = !plot || ln.y - labelH >= plot.y;
return (
<g key={`lvl-${i}`}>
<line
x1={ln.x1}
x2={right}
y1={ln.y}
y2={ln.y}
stroke={ln.color}
strokeWidth={2}
strokeOpacity={0.85}
/>
<text
x={(right ?? ln.x1) - 4}
y={labelAbove ? ln.y - 4 : ln.y + 11}
textAnchor="end"
fill={ln.color}
fontSize={10}
>
{ln.label}
</text>
</g>
);
})}
</g>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,408 @@
"use client";
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { DealerMapCell, DealerMapPayload } from "@/lib/trpc";
import {
formatCountCompact,
formatMetricValue,
formatUsdCompact,
metricLabel,
metricMaxAbs,
metricValue,
type MetricMode,
type ProfileMode,
} from "@/lib/dealerMapChrome";
/** Signed heat for GEX/VEX; intensity-only for OI. Flat fills - no card chrome. */
function metricColor(value: number, maxAbs: number, mode: MetricMode): string {
if (maxAbs <= 0) return "bg-surface-sunken text-fg-faint";
const t = Math.min(1, Math.abs(value) / maxAbs);
if (Math.abs(value) < maxAbs * 0.05) return "bg-surface-sunken/70 text-fg-faint";
if (mode === "oi") {
if (t > 0.66) return "bg-sky-500/85 text-white";
if (t > 0.33) return "bg-sky-500/50 text-fg";
return "bg-sky-500/25 text-fg-muted";
}
if (value > 0) {
if (t > 0.66) return "bg-amber-400/90 text-black";
if (t > 0.33) return "bg-amber-400/50 text-fg";
return "bg-emerald-500/35 text-fg";
}
if (t > 0.66) return "bg-fuchsia-600/90 text-white";
if (t > 0.33) return "bg-fuchsia-600/55 text-fg";
return "bg-fuchsia-900/40 text-fg-muted";
}
function barFillClass(value: number, mode: MetricMode): string {
if (mode === "oi") return "bg-sky-500/80";
return value >= 0 ? "bg-amber-400/80" : "bg-fuchsia-600/80";
}
function cellPolarityLabel(polarity: DealerMapCell["polarity"]): string {
if (polarity === "pos") return "+GEX (stabilize)";
if (polarity === "neg") return "−GEX (amplify)";
return "Near zero";
}
function cellPolarityTip(polarity: DealerMapCell["polarity"]): string {
if (polarity === "pos") {
return "Positive modeled gamma at this node is often associated with hedges that lean against price moves.";
}
if (polarity === "neg") {
return "Negative modeled gamma at this node is often associated with hedges that lean with price moves.";
}
return "Thin modeled exposure - less mechanical damping or pull from this cell alone.";
}
const CELL_TIP_W = 224;
const CELL_TIP_GAP = 8;
const CELL_TIP_PAD = 8;
function CellHoverDetail({
cell,
children,
}: {
cell: DealerMapCell;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
const tipRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const place = useCallback(() => {
const el = anchorRef.current;
if (!el || typeof window === "undefined") return;
const r = el.getBoundingClientRect();
const tipH = tipRef.current?.offsetHeight ?? 160;
const vw = window.innerWidth;
const vh = window.innerHeight;
let top = r.top - tipH - CELL_TIP_GAP;
if (top < CELL_TIP_PAD) top = r.bottom + CELL_TIP_GAP;
if (top + tipH > vh - CELL_TIP_PAD) {
top = Math.max(CELL_TIP_PAD, vh - tipH - CELL_TIP_PAD);
}
let left = r.left + r.width / 2 - CELL_TIP_W / 2;
left = Math.min(Math.max(CELL_TIP_PAD, left), vw - CELL_TIP_W - CELL_TIP_PAD);
setPos({ top, left });
}, []);
useLayoutEffect(() => {
if (!open) {
setPos(null);
return;
}
place();
const id = requestAnimationFrame(place);
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
return () => {
cancelAnimationFrame(id);
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
}, [open, place, cell.strike, cell.expiry]);
const tip =
open && pos && typeof document !== "undefined"
? createPortal(
<div
ref={tipRef}
role="tooltip"
style={{ position: "fixed", top: pos.top, left: pos.left, width: CELL_TIP_W }}
className="z-[100] p-2.5 rounded-md border border-line bg-surface-raised shadow-lg text-[11px] text-fg-muted leading-relaxed pointer-events-none"
>
<p className="text-sm font-bold text-fg tabular-nums">
{cell.strike}
{cell.isKing ? " ★" : ""}
<span className="font-normal text-fg-faint text-[10px] ml-1">{cell.expiry}</span>
</p>
<dl className="mt-1.5 grid grid-cols-2 gap-1.5">
<div>
<dt className="text-[9px] uppercase text-fg-faint">GEX</dt>
<dd className="font-semibold tabular-nums text-fg">{formatUsdCompact(cell.gex)}</dd>
</div>
<div>
<dt className="text-[9px] uppercase text-fg-faint">VEX</dt>
<dd className="font-semibold tabular-nums text-fg">{formatUsdCompact(cell.vex)}</dd>
</div>
<div>
<dt className="text-[9px] uppercase text-fg-faint">OI</dt>
<dd className="font-semibold tabular-nums text-fg">{cell.oi.toLocaleString()}</dd>
</div>
<div>
<dt className="text-[9px] uppercase text-fg-faint">Opt vol</dt>
<dd className="font-semibold tabular-nums text-fg">
{(cell.volume ?? 0).toLocaleString()}
</dd>
</div>
<div className="col-span-2">
<dt className="text-[9px] uppercase text-fg-faint">Polarity</dt>
<dd className="font-semibold text-fg">{cellPolarityLabel(cell.polarity)}</dd>
</div>
</dl>
<p className="mt-1.5 text-[10px] text-fg-faint">{cellPolarityTip(cell.polarity)}</p>
</div>,
document.body,
)
: null;
return (
<div
ref={anchorRef}
className="relative h-full"
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocusCapture={() => setOpen(true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOpen(false);
}}
>
{children}
{tip}
</div>
);
}
/** Strike window: spot ±8% expanded to always include king / flip / walls. */
function focusedStrikes(map: DealerMapPayload): number[] {
const all = [...new Set(map.cells.map((c) => c.strike))].sort((a, b) => b - a);
if (all.length === 0) return all;
const anchors: number[] = [map.spot];
if (map.levels.king) anchors.push(map.levels.king.strike);
if (map.levels.flip != null) anchors.push(map.levels.flip);
if (map.levels.callWall != null) anchors.push(map.levels.callWall);
if (map.levels.putWall != null) anchors.push(map.levels.putWall);
for (const g of map.levels.gatekeepers ?? []) anchors.push(g.strike);
const pad = Math.max(map.spot * 0.01, 0.5);
const lo = Math.min(map.spot * 0.92, ...anchors) - pad;
const hi = Math.max(map.spot * 1.08, ...anchors) + pad;
const focused = all.filter((k) => k >= lo && k <= hi);
return focused.length > 0 ? focused : all.slice(0, 40);
}
function nearestSpotStrike(strikes: number[], spot: number): number | null {
if (strikes.length === 0) return null;
let best = strikes[0];
let bestD = Math.abs(best - spot);
for (const s of strikes) {
const d = Math.abs(s - spot);
if (d < bestD) {
best = s;
bestD = d;
}
}
return best;
}
export function FlowGrid({
map,
metric,
profile,
}: {
map: DealerMapPayload;
metric: MetricMode;
profile: ProfileMode;
}) {
const maxAbs = useMemo(() => metricMaxAbs(map.cells, metric) || 1, [map.cells, metric]);
const strikes = useMemo(() => focusedStrikes(map), [map]);
const spotStrike = useMemo(() => nearestSpotStrike(strikes, map.spot), [strikes, map.spot]);
const byKey = useMemo(() => {
const m = new Map<string, DealerMapCell>();
for (const c of map.cells) m.set(`${c.expiry}|${c.strike}`, c);
return m;
}, [map.cells]);
const profileByStrike = useMemo(() => {
const m = new Map<number, number>();
if (profile === "off") return m;
const window = new Set(strikes);
for (const c of map.cells) {
if (!window.has(c.strike)) continue;
const add = profile === "oi" ? c.oi : (c.volume ?? 0);
m.set(c.strike, (m.get(c.strike) ?? 0) + add);
}
return m;
}, [map.cells, profile, strikes]);
const profileMax = useMemo(() => {
let m = 0;
for (const v of profileByStrike.values()) m = Math.max(m, v);
return m || 1;
}, [profileByStrike]);
return (
<div className="overflow-auto max-h-[calc(100dvh-12rem)]">
<table className="w-full border-collapse text-[11px] min-w-[280px]">
<thead>
<tr>
<th className="sticky top-0 left-0 z-20 bg-surface p-1 text-left text-[10px] uppercase tracking-wide text-fg-faint font-medium">
Strike
</th>
{profile !== "off" && (
<th className="sticky top-0 z-10 bg-surface p-1 text-center text-[10px] uppercase tracking-wide text-fg-faint font-medium w-12">
{profile === "oi" ? "OI" : "Vol"}
</th>
)}
{map.expiries.map((e) => (
<th
key={e}
className="sticky top-0 z-10 bg-surface p-1 text-center text-[10px] text-fg-faint font-medium whitespace-nowrap"
>
{e.slice(5)}
</th>
))}
</tr>
</thead>
<tbody>
{strikes.map((strike) => {
const isSpot = strike === spotStrike;
const prof = profileByStrike.get(strike) ?? 0;
const profW = Math.max(4, (prof / profileMax) * 100);
return (
<tr key={strike}>
<td className="sticky left-0 z-10 bg-surface p-0.5 tabular-nums">
<div className="flex items-center gap-1 min-h-[26px]">
{isSpot ? (
<span className="inline-flex items-center rounded-full bg-fg text-surface text-[11px] font-semibold px-1.5 py-0.5 leading-none">
{strike}
</span>
) : (
<span className="text-fg-muted px-1.5">{strike}</span>
)}
{map.levels.putWall === strike ? (
<span className="text-[9px] font-medium text-fuchsia-400">P</span>
) : null}
{map.levels.callWall === strike ? (
<span className="text-[9px] font-medium text-amber-400">C</span>
) : null}
{map.levels.flip === strike ? (
<span className="text-[9px] font-medium text-warn">F</span>
) : null}
</div>
</td>
{profile !== "off" && (
<td className="p-0.5 w-12">
<div className="h-[26px] flex items-center">
<div className="w-full h-1.5 bg-surface-sunken overflow-hidden">
<div
className={`h-full ${profile === "oi" ? "bg-sky-500/70" : "bg-fg-muted/50"}`}
style={{ width: `${profW}%` }}
/>
</div>
</div>
</td>
)}
{map.expiries.map((expiry) => {
const cell = byKey.get(`${expiry}|${strike}`);
if (!cell) {
return (
<td key={expiry} className="p-px">
<div className="h-[26px] min-w-[3.25rem] bg-surface-sunken/30" />
</td>
);
}
const v = metricValue(cell, metric);
return (
<td key={expiry} className="p-px">
<CellHoverDetail cell={cell}>
<div
tabIndex={0}
className={`h-[26px] min-w-[3.25rem] w-full px-0.5 flex items-center justify-center cursor-help ${metricColor(
v,
maxAbs,
metric,
)} ${cell.isKing ? "ring-1 ring-inset ring-warn" : ""} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
aria-label={`Strike ${strike} expiry ${expiry}. ${metricLabel(metric)} ${formatMetricValue(v, metric)}. GEX ${formatUsdCompact(cell.gex)}. VEX ${formatUsdCompact(cell.vex)}. OI ${cell.oi}. ${cellPolarityLabel(cell.polarity)}.`}
>
<span className="font-semibold tabular-nums leading-none text-[11px]">
{cell.isKing ? "★ " : ""}
{formatMetricValue(v, metric)}
</span>
</div>
</CellHoverDetail>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
export function BarView({ map, metric }: { map: DealerMapPayload; metric: MetricMode }) {
const nearest = map.expiries[0];
const cells = useMemo(() => {
const nearestCells = map.cells
.filter((c) => c.expiry === nearest)
.sort((a, b) => a.strike - b.strike);
const window = new Set(focusedStrikes(map));
const focused = nearestCells.filter((c) => window.has(c.strike));
return focused.length >= 4 ? focused : nearestCells;
}, [map, nearest]);
const maxAbs = metricMaxAbs(cells, metric) || 1;
const signed = metric !== "oi";
const spotStrike = nearestSpotStrike(
cells.map((c) => c.strike),
map.spot,
);
return (
<div className="space-y-0.5 max-h-[calc(100dvh-12rem)] overflow-y-auto">
<p className="text-[11px] text-fg-faint mb-1">
Nearest expiry {nearest} · {metricLabel(metric)}
</p>
{cells.map((c) => {
const v = metricValue(c, metric);
const w = Math.max(4, (Math.abs(v) / maxAbs) * 100);
const isSpot = c.strike === spotStrike;
return (
<CellHoverDetail key={`${c.expiry}-${c.strike}`} cell={c}>
<div
tabIndex={0}
className="w-full flex items-center gap-2 min-h-[28px] px-1 cursor-help focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
aria-label={`Strike ${c.strike}. ${metricLabel(metric)} ${formatMetricValue(v, metric)}. GEX ${formatUsdCompact(c.gex)}. VEX ${formatUsdCompact(c.vex)}.`}
>
<span className="w-14 shrink-0 text-right">
{isSpot ? (
<span className="inline-flex items-center rounded-full bg-fg text-surface text-[11px] font-semibold px-1.5 py-0.5 tabular-nums">
{c.strike}
</span>
) : (
<span className="text-xs tabular-nums text-fg-muted">
{c.strike}
{c.isKing ? "★" : ""}
{map.levels.putWall === c.strike ? " P" : ""}
{map.levels.callWall === c.strike ? " C" : ""}
</span>
)}
</span>
<div className="flex-1 h-4 bg-surface-sunken overflow-hidden flex">
<div
className={`h-full ${barFillClass(v, metric)}`}
style={{
width: `${w}%`,
marginLeft: signed ? (v >= 0 ? "50%" : `${50 - w}%`) : "0%",
}}
/>
</div>
<span className="w-14 text-[10px] tabular-nums text-fg-faint text-right shrink-0">
{formatMetricValue(v, metric)}
</span>
</div>
</CellHoverDetail>
);
})}
</div>
);
}
@@ -39,8 +39,16 @@ function downloadCsv(filename: string, csv: string) {
const MENTOR_BLEND_KEY = "iflow-study-use-mentor-history";
export function StudyDeskPanel({ symbol }: { symbol: string }) {
const [open, setOpen] = useState(false);
export function StudyDeskPanel({
symbol,
embedded = false,
}: {
symbol: string;
/** Parent owns the trigger (Dealer Flow drawer). Body only. */
embedded?: boolean;
}) {
const [internalOpen, setInternalOpen] = useState(false);
const open = embedded ? true : internalOpen;
const [tab, setTab] = useState<"study" | "mentor">("study");
const [proposals, setProposals] = useState<DealerStudyProposal[]>([]);
const [message, setMessage] = useState<string | null>(null);
@@ -225,22 +233,30 @@ export function StudyDeskPanel({ symbol }: { symbol: string }) {
};
return (
<section className="rounded-xl border border-line bg-surface-raised p-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold text-fg">Study Desk</h2>
<p className="text-[11px] text-fg-faint mt-0.5">
Practice setups from the map.
</p>
<section
className={
embedded
? "space-y-3"
: "rounded-xl border border-line bg-surface-raised p-4 space-y-3"
}
>
{!embedded && (
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-sm font-semibold text-fg">Study Desk</h2>
<p className="text-[11px] text-fg-faint mt-0.5">
Practice setups from the map.
</p>
</div>
<button
type="button"
className="min-h-[40px] px-3 rounded-lg border border-line text-xs text-fg-muted"
onClick={() => setInternalOpen((v) => !v)}
>
{open ? "Hide" : "Open"}
</button>
</div>
<button
type="button"
className="min-h-[40px] px-3 rounded-lg border border-line text-xs text-fg-muted"
onClick={() => setOpen((v) => !v)}
>
{open ? "Hide" : "Open"}
</button>
</div>
)}
{open && (
<div className="space-y-4">
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { lastUpdatedText, type LastUpdatedProps as CopyProps } from "@/lib/lastUpdatedCopy";
export type { PullStatus } from "@/lib/lastUpdatedCopy";
export interface LastUpdatedProps extends CopyProps {
className?: string;
}
function safeIso(at: string | null | undefined): string | undefined {
if (!at) return undefined;
const t = Date.parse(at);
if (!Number.isFinite(t)) return undefined;
return new Date(t).toISOString();
}
/** Compact provenance stamp. Native title holds the exact ISO time. */
export function LastUpdated(props: LastUpdatedProps) {
const text = lastUpdatedText(props);
const exact = safeIso(props.at);
return (
<span
className={props.className ?? "text-[11px] text-fg-muted tabular-nums"}
title={exact}
>
{text}
</span>
);
}
+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 {