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