/* ─────────────────────────────────────────────────────────
components.jsx — reusable bits for the dashboard
Export each to window so the main app.jsx can use them.
───────────────────────────────────────────────────────── */
const { useState, useMemo, useRef, useEffect } = React;
/* ───────── format helpers ───────── */
const fmtUSD = (n, opts = {}) => {
const { showSign = false, decimals = 2 } = opts;
const sign = n > 0 ? "+" : n < 0 ? "−" : "";
const abs = Math.abs(n).toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
return (showSign ? sign : (n < 0 ? "−" : "")) + "$" + abs;
};
const fmtPct = (n, decimals = 1) => {
const sign = n > 0 ? "+" : n < 0 ? "−" : "";
return sign + Math.abs(n).toFixed(decimals) + "%";
};
// Clock-time display. On the PUBLIC demo (window.TTO_DEMO) every scanner-pick /
// buy / sell time is BLURRED — the demo must never reveal the owner's real
// strategy timing, and the real values aren't in the demo's data either (see
// demo-data.js). On the authenticated dashboard (window.TTO_AUTH, never
// TTO_DEMO) the real time renders normally; "—" when missing.
const IS_DEMO = (typeof window !== "undefined") && window.TTO_DEMO === true;
function ClockTime({ value }) {
if (IS_DEMO) {
return ••:•• ;
}
return value || "—";
}
// Price display that mirrors ClockTime's redaction. On the PUBLIC demo
// (window.TTO_DEMO) the value is BLURRED — the demo must never reveal the
// owner's real chosen/buy/sell prices, and demo-data.js ships none anyway. On
// the authenticated dashboard (window.TTO_AUTH, never TTO_DEMO) the real price
// renders as USD; "—" when missing/null. Same .redacted-time treatment + alias.
function RedactedPrice({ value }) {
if (IS_DEMO) {
return $••.•• ;
}
return (value || value === 0) ? fmtUSD(value) : "—";
}
const fmtDateShort = (iso) => {
const d = new Date(iso + "T12:00:00");
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
const pnlClass = (n) => (n > 0 ? "pos" : n < 0 ? "neg" : "");
/* ───────── Tiny sparkline ───────── */
function Sparkline({ values, color = "var(--text-2)", height = 28, fill = false }) {
if (!values || values.length === 0) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const W = 100, H = 30;
const span = max - min || 1;
const pts = values.map((v, i) => {
const x = (i / (values.length - 1)) * W;
const y = H - ((v - min) / span) * H;
return [x, y];
});
const d = pts.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(2) + "," + p[1].toFixed(2)).join(" ");
const dFill = d + ` L${W},${H} L0,${H} Z`;
return (
{fill && }
);
}
/* ───────── ET clock + market status hook ─────────
Mirrors the timing_optimizer reference:
- 00:00–04:00 ET → AFTER-HRS
- 04:00–09:30 ET → PRE-MKT
- 09:30–16:00 ET → OPEN
- 16:00–24:00 ET → AFTER-HRS
Updates every second.
─────────────────────────────────────────────────── */
function getETParts(d = new Date()) {
// Use Intl to reliably read NY time regardless of viewer's local tz
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
hour: "2-digit", minute: "2-digit", second: "2-digit",
hour12: false, weekday: "short",
year: "numeric", month: "2-digit", day: "2-digit",
});
const parts = fmt.formatToParts(d);
const get = k => parts.find(p => p.type === k)?.value;
const h = parseInt(get("hour"), 10) % 24;
const m = parseInt(get("minute"), 10);
const s = parseInt(get("second"), 10);
const weekday = get("weekday"); // "Mon" "Tue" ...
const isoDate = `${get("year")}-${get("month")}-${get("day")}`;
return { h, m, s, weekday, isoDate, totalMin: h * 60 + m };
}
function getMarketStatus(parts) {
const { totalMin, weekday } = parts;
const isWeekend = weekday === "Sat" || weekday === "Sun";
if (isWeekend) return { label: "WEEKEND", color: "var(--text-3)", isAfterHours: true, isOpen: false };
if (totalMin < 240) return { label: "AFTER-HRS", color: "var(--warn)", isAfterHours: true, isOpen: false };
if (totalMin < 570) return { label: "PRE-MKT", color: "var(--warn)", isAfterHours: false, isOpen: false };
if (totalMin < 960) return { label: "OPEN", color: "var(--pos)", isAfterHours: false, isOpen: true };
return { label: "AFTER-HRS", color: "var(--warn)", isAfterHours: true, isOpen: false };
}
function useETClock() {
const [now, setNow] = React.useState(() => new Date());
React.useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const parts = getETParts(now);
const status = getMarketStatus(parts);
return { parts, status, now };
}
function fmtETTime12(parts) {
const { h, m, s } = parts;
const period = h >= 12 ? "PM" : "AM";
const h12 = ((h + 11) % 12) + 1;
const pad = n => String(n).padStart(2, "0");
return `${h12}:${pad(m)}:${pad(s)} ${period}`;
}
/* Next-trading-day label, used in the hero when after hours.
- Friday after hours → Monday
- Sat/Sun → Monday
- Other days after hours → tomorrow's weekday + date */
function nextTradingDayLabel(parts) {
// Walk forward at least 1 calendar day, skip Sat/Sun
const base = new Date(parts.isoDate + "T12:00:00Z");
base.setUTCDate(base.getUTCDate() + 1);
while ([0, 6].includes(base.getUTCDay())) base.setUTCDate(base.getUTCDate() + 1);
return base.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", timeZone: "UTC" });
}
/* Possessive word for the NEXT trading day, used in the next-trade tile labels
("Tomorrow's stake" vs "Monday's stake"). Walks forward from today's ET date,
skipping Sat/Sun (weekend-aware floor; the generator can pre-skip holidays via
the next_fire_at it ships). Returns:
- "Tomorrow" when the next trading day is literally the next calendar day
(e.g. a Mon–Thu evening → the following morning).
- The weekday name otherwise (e.g. Fri/Sat/Sun → "Monday").
`parts` is the ET clock parts ({ isoDate, ... }); falls back to "the next
trading day" when parts is absent. */
function nextTradingDayWord(parts) {
if (!parts || !parts.isoDate) return "the next trading day";
const today = new Date(parts.isoDate + "T12:00:00Z");
const next = new Date(today.getTime());
next.setUTCDate(next.getUTCDate() + 1);
while ([0, 6].includes(next.getUTCDay())) next.setUTCDate(next.getUTCDate() + 1);
const oneDayMs = 24 * 60 * 60 * 1000;
const isTomorrow = Math.round((next - today) / oneDayMs) === 1;
return isTomorrow ? "Tomorrow" : next.toLocaleDateString("en-US", { weekday: "long", timeZone: "UTC" });
}
/* ───────── Logo variants ───────── */
function LogoVariant({ variant = "candlesticks" }) {
if (variant === "candlesticks") {
return (
{/* Gold clock mark */}
The {" "}
Trading {" "}
Optimizer
);
}
if (variant === "monogram") {
return (
tO
trading-optimizer
);
}
if (variant === "ticker") {
return (
$
TO
TRADING
OPTIMIZER
);
}
if (variant === "target") {
return (
{/* Crosshair ticks */}
/
OPTIMIZER
/
);
}
if (variant === "network") {
return (
{/* Constellation of trade-day nodes connected by a path */}
trading
·
optimizer
);
}
if (variant === "bracket") {
return (
[
10:45 → 13:00
TRADING OPTIMIZER
]
);
}
return null;
}
Object.assign(window, { LogoVariant });
/* ───────── Account switcher tabs ───────── */
function AccountTabs({ accounts, selected, onSelect }) {
return (
Accounts
{accounts.map((acct, i) => {
const isOn = selected === i;
const dayPos = acct.account.day_pnl_dollars >= 0;
return (
onSelect(i)}
style={isOn ? { "--accent": acct.accentColor } : undefined}
>
{acct.accountLast4 ? "Account ••" + acct.accountLast4 + " · Paper" : "Paper account"}
{acct.label}
{acct.strategyKindLabel}
{acct.mixedStrategies && (
· traded with mixed strategies
)}
${acct.account.portfolio_value.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
{fmtUSD(acct.account.day_pnl_dollars, { showSign: true })}
);
})}
);
}
/* ───────── Bot status derivation ─────────
Two states, derived from the day's real position/cycle data + the live clock:
• "BOT ACTIVE" (green) — the bot is mid-cycle: there's an OPEN position right
now (bought, waiting to sell), OR the cron has fired today and the day's
sell hasn't completed yet. i.e. holding / in-flight.
• "BOT STANDING BY" (red) — the last trade is done and there are no open
positions: overnight, after-hours after the sell, pre-cron, no-trade days,
weekends/holidays.
`args` carries only the fields needed (so it stays unit-testable):
resolvedState — the app's resolved today-state ("holding"|"closed"|…)
openPositions — live open-position count (0 when flat)
livePositionTicker— real-time held ticker ("" when flat)
`now` is the live ET clock parts ({ totalMin, weekday, … }); reserved for
future calendar-aware refinement (we already fold market-day into the state). */
function botState(args, now) {
const a = args || {};
const mins = (now && typeof now.totalMin === "number") ? now.totalMin : -1;
// Mid-cycle: holding an open position (bought, waiting for the scheduled sell).
const holding =
a.resolvedState === "holding" ||
(a.openPositions || 0) > 0 ||
!!a.livePositionTicker;
// Scan window: the cron has STARTED (>= 09:12 ET = 552 min) on a trading-day
// morning, but the bot hasn't logged a completed cycle yet (resolvedState
// "pre_market") — i.e. it's scanning / waiting to buy. ACTIVE, per "BOT ACTIVE
// when the cron job has started". (Before 09:12 it's still pre_market but reads
// STANDING BY — the cron hasn't fired.) pre_market only resolves on weekdays
// that aren't after-hours and where today's log isn't in yet (see app.jsx).
const scanning = a.resolvedState === "pre_market" && mins >= 552;
if (holding || scanning) {
return { label: "Bot active", cls: "bot-active",
tip: (scanning && !holding)
? "The cron has started — scanning for today's top mover (pre-buy)."
: "Mid-cycle — holding an open position, waiting for the scheduled sell." };
}
return { label: "Bot standing by", cls: "bot-standby",
tip: "Idle — the last trade is settled and no position is open. The cron fires next on the next trading day." };
}
function Topbar({ botHealth, asOf, clock, logoVariant = "candlesticks", botStatus }) {
const timeStr = clock ? fmtETTime12(clock.parts) : "—";
const status = clock?.status;
const bs = botStatus || { label: "Bot standing by", cls: "bot-standby", tip: "" };
return (
{window.TTO_AUTH && (
⚙ Settings
·
window.TTO_signOut && window.TTO_signOut()}>Sign out
)}
Current Time
{timeStr} ET
Market Hours
{status?.label || "—"}
{bs.label}
);
}
/* ───────── Hero / TODAY ───────── */
function TodayHero({ plan, account, streakHistory, currentStreak, botHealth, equitySpark, asOf, acct, clock }) {
const isStrategyB = acct && acct.strategyKind === "B";
// Variable central content per state
let badge = null;
let ticker = plan.ticker || "—";
let tickerCls = "tile-hero-ticker";
let context = null;
if (plan.state === "holding") {
badge = Live · Holding ;
if (plan.detail_pending) {
// Live open position detected (from the accounts table), but the blob
// hasn't refreshed its entry/close detail yet — show a graceful note.
context = (
Position opened today — entry & close times update shortly.
);
} else {
const h = Math.floor(plan.closes_in_minutes / 60);
const m = plan.closes_in_minutes % 60;
context = (
Buy
Sell
closes in {h}h {m}m
);
}
} else if (plan.state === "closed") {
const won = (plan.realized_pnl ?? 0) >= 0;
badge = Closed · {won ? "win" : "loss"} settled ;
context = (
);
} else if (plan.state === "pre_market") {
badge = Pre-market · queued ;
const h = Math.floor(plan.fires_in_minutes / 60);
const m = plan.fires_in_minutes % 60;
context = (
Scanner picked {plan.ticker} · order fires in {h}h {m}m at
);
} else if (plan.state === "no_trade") {
ticker = "NO TRADE";
tickerCls = "tile-hero-ticker no-trade";
badge = Scanner · no qualifying mover ;
context = (
{plan.reason}
);
} else if (plan.state === "weekend") {
ticker = "MARKETS CLOSED";
tickerCls = "tile-hero-ticker no-trade";
badge = Weekend · cron paused ;
const h = Math.floor(plan.next_fire_in_minutes / 60);
const m = plan.next_fire_in_minutes % 60;
context = (
Next cron fires {plan.next_fire_at} · in {h}h {m}m
);
} else if (plan.state === "next_day") {
ticker = "AWAITING SCANNER";
tickerCls = "tile-hero-ticker no-trade";
// "Tomorrow" only when the next trading day is literally tomorrow; on a
// Fri/Sat/Sun (or a holiday eve the generator pre-skipped) it's e.g. "Monday".
const nextDayWord = nextTradingDayWord(clock ? clock.parts : null);
const nextDayPoss = nextDayWord === "Tomorrow" ? "Tomorrow's" : nextDayWord + "'s";
badge = {plan.first_run ? "New account · first trade pending" : `After-hours · ${nextDayPoss.toLowerCase()} plan`} ;
context = (
{/* ONE stake line — the next trading day's PROJECTED stake (was
duplicated as a separate "Stake" + "Tomorrow's stake" pair). For
Strategy A this is 10%-of-cash base scaled by the streak formula;
for B it's the full portfolio. */}
{nextDayPoss} stake
{plan.next_stake_main || plan.formula}
{plan.next_stake_sub || (isStrategyB ? "always-on compounding" : "")}
{plan.first_run
? "Your account is linked and active. The scanner runs at 09:40 ET each market day and places your first trade automatically — your trade history and equity curve fill in here once it does."
: <>{plan.stake_explanation} {plan.note}>}
);
}
const isActive = plan.state === "holding" || plan.state === "closed" || plan.state === "pre_market";
const showStake = isActive || plan.state === "next_day";
// Today P&L value (unrealized or realized; 0 otherwise)
const todayPnl = plan.unrealized_pnl ?? plan.realized_pnl ?? 0;
const asOfDate = asOf ? new Date(asOf) : new Date();
return (
{plan.state === "next_day" ? "Today's trading day" : "Today"}
{(() => {
const d = clock ? new Date(clock.parts.isoDate + "T12:00:00Z") : asOfDate;
const opts = { weekday: "long", month: "long", day: "numeric", timeZone: clock ? "UTC" : undefined };
return d.toLocaleDateString("en-US", opts);
})()}
{plan.state === "next_day"
? `cron fires next at ${botHealth.cron_fire_hhmm || "09:12"} ET`
: `cron @ ${botHealth.cron_fire_hhmm || "09:12"} ET · scan 09:40 ET · last fire ${botHealth.last_cron_at}`}
{/* Dominant tile */}
{badge}
{isActive ? plan.formula : ""}
{ticker}
{plan.ticker_long && isActive && (
{plan.ticker_long}
)}
{plan.ticker_sector && isActive && (
{plan.ticker_sector}{plan.ticker_exchange ? ` · ${plan.ticker_exchange}` : ""}
{plan.ticker_industry ? {` · ${plan.ticker_industry}`} : ""}
)}
{plan.order_rejection && (
{plan.order_rejection}
)}
{context}
{plan.state === "holding" && !plan.detail_pending && (
Last · Bought
${plan.last_price.toFixed(2)} / ${plan.bought_at.toFixed(2)}
)}
{plan.state === "closed" && (
Buy
${plan.buy_price.toFixed(2)}
Sell
${plan.sell_price.toFixed(2)}
Shares
{plan.shares != null ? Number(plan.shares).toFixed(1) : "0.0"}
)}
{/* Supporting tiles */}
{/* Stake tile (today) OR Today's trade tile (next_day) */}
{plan.state === "next_day" && plan.today_settled ? (
Today's trade
{plan.today_settled.ticker}
Buy
${plan.today_settled.buy_price.toFixed(2)}
→
Sell
${plan.today_settled.sell_price.toFixed(2)}
{plan.today_settled.shares.toLocaleString()} shares of {plan.today_settled.ticker}
) : (
Stake deployed
{showStake && plan.stake_basis && (
{plan.stake_basis}
)}
{showStake ? "$" + plan.stake.toLocaleString() : — }
{showStake
? (plan.stake_explanation || "Stake set from current streak formula.")
: "No position deployed today."}
)}
Streak
last 8
{isStrategyB
? —n/a
: currentStreak.count === 0
? 0reset
:
{currentStreak.count}{currentStreak.kind}
}
{streakHistory.map((s, i) => )}
{!isStrategyB && currentStreak.count === 0 && !plan.first_run && currentStreak.lastBreak && (() => {
const b = currentStreak.lastBreak;
// broke_from is "{N}W" (a win streak ended by a loss) or "{N}L" (a
// loss streak ended by a win) — its last char drives both the
// result word and the badge color (green for the prior W, red for L).
const brokeKind = (b.broke_from || "").slice(-1);
const resultWord = brokeKind === "L" ? "win" : "loss";
const kindClass = brokeKind === "L" ? "value-neg" : "value-pos";
return (
broken {fmtDateShort(b.date)} from {b.broke_from} by {b.ticker} {resultWord}
);
})()}
{isStrategyB && (
Strategy B always deploys full portfolio — no streak formula.
)}
{(plan.state === "next_day" && !plan.first_run) ? "Today's result" : "Today P&L"}
{plan.state === "next_day" && plan.today_settled && (
{plan.today_settled.ticker}
)}
{plan.state === "next_day" && plan.today_settled
? (() => {
const pnl = plan.today_settled.realized_pnl;
const pct = plan.today_settled.realized_pnl_pct;
const cls = pnl > 0 ? "value-pos" : pnl < 0 ? "value-neg" : "value-neutral";
return (
<>
{fmtUSD(pnl, { showSign: true })}
{fmtPct(pct, 2)}
· settled at 13:00 ET · {plan.today_settled.shares}sh of {plan.today_settled.ticker}
>
);
})()
: (() => {
// The % must describe the SAME number as the dollar value
// above it: position unrealized % while holding, the trade's
// realized % once closed. Account-level day % is only the
// fallback (it mixes in overnight equity drift).
const pctRaw = plan.state === "holding"
? (plan.unrealized_pnl_pct != null ? plan.unrealized_pnl_pct
: (account && account.day_pnl_percent != null ? account.day_pnl_percent : 0))
: plan.state === "closed"
? (plan.realized_pnl_pct != null ? plan.realized_pnl_pct
: (account && account.day_pnl_percent != null ? account.day_pnl_percent : 0))
: (account && account.day_pnl_percent != null
? account.day_pnl_percent
: (plan.realized_pnl_pct || 0));
const pctCls = todayPnl > 0 ? "value-pos"
: todayPnl < 0 ? "value-neg"
: "value-neutral";
return (
<>
{isActive ? fmtUSD(todayPnl, { showSign: true })
: $0.00 }
{isActive && (
{fmtPct(pctRaw, 2)}
)}
{plan.state === "holding" ? "unrealized · marks every tick"
: plan.state === "closed" ? "realized · settled"
: "no trade today"}
>
);
})()}
Portfolio value
${account.portfolio_value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
Last cron fire
{botHealth.last_cron_at}
status
{botHealth.last_cron_status === "ok" ? "OK" : "CHECK"}
· {botHealth.consecutive_successful_fires} successful in a row · backup {botHealth.backup_age_hours}h old
Open positions
{account.open_positions_count === 0
? 0
: account.open_positions_count}
{account.open_positions_count > 0 && plan.ticker && (
· {plan.ticker}
)}
cash ${account.cash.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })} available
);
}
/* ───────── Stats row ───────── */
function StatsRow({ perf }) {
return (
Total return
since 2026-03-31
0 ? "value-pos" : "value-neg")}>
{fmtPct(perf.totalReturnPct, 2)}
0 ? "value-pos" : "value-neg"}>
{fmtUSD(perf.totalReturnDollars, { showSign: true })}
on ${perf.startingBalance.toLocaleString()} starting equity
Win rate
{perf.winRate.toFixed(1)}%
{perf.wins}W
/
{perf.losses}L
of {perf.totalTrades} trades · Sharpe {perf.sharpe.toFixed(1)}
Best · Worst day
Best
{fmtUSD(perf.bestDay.pnl_dollars, { showSign: true })}
{perf.bestDay.ticker} · {fmtDateShort(perf.bestDay.date)}
Worst
{fmtUSD(perf.worstDay.pnl_dollars, { showSign: true })}
{perf.worstDay.ticker} · {fmtDateShort(perf.worstDay.date)}
);
}
/* ───────── Main chart — tabbed (Strategy-A-style) ─────────
Tabs: Portfolio Value · Daily P&L ($) · Daily P&L (%) · Cumulative P&L
Range: 3D · 7D · 14D · 1M · All
─────────────────────────────────────────────────────────── */
const TAB_DEFS = [
{ id: "portfolio", label: "Portfolio Value",
tip: "Account value at each daily close.\nThe headline strategy curve — answers \"is this making money over time?\"" },
{ id: "daily", label: "Daily P&L ($)",
tip: "Per-day realized profit/loss in dollars.\nBars colored green positive, red negative." },
{ id: "dailypct", label: "Daily P&L (%)",
tip: "Per-day P&L as a percent of the stake deployed that day.\nNormalizes for variable position sizing — apples-to-apples across streak-scaled days." },
{ id: "cumulative", label: "Cumulative P&L",
tip: "Running sum of daily P&L from the start of the window.\nSame trajectory as Portfolio Value minus starting balance." },
];
const RANGE_DEFS = [
{ id: "3D", label: "3D", days: 3 },
{ id: "7D", label: "7D", days: 7 },
{ id: "14D", label: "14D", days: 14 },
{ id: "1M", label: "1M", days: 22 },
{ id: "3M", label: "3M", days: 66 },
{ id: "6M", label: "6M", days: 126 },
{ id: "1Y", label: "1Y", days: 252 },
{ id: "5Y", label: "5Y", days: 1260 },
{ id: "All", label: "All", days: Infinity },
];
function MainChart({ equityCurve, dailyPnl, tradeByDate, tickerMeta, accentColor = "#cda85c" }) {
const [tab, setTab] = useState("portfolio");
// View window expressed as data-array indices [start, end). Range pills are
// shortcuts that snap this window; pan/pinch/wheel mutate it directly.
const total = equityCurve.length;
const defaultDays = Math.min(35, total);
const [view, setView] = useState({ start: total - defaultDays, end: total });
const [isDragging, setIsDragging] = useState(false);
// Refs for pointer interaction (mutating per-event without re-rendering)
const bodyRef = useRef(null);
const pointersRef = useRef(new Map());
const dragRef = useRef(null);
const pinchRef = useRef(null);
function snapToRange(rangeId) {
const def = RANGE_DEFS.find(r => r.id === rangeId);
if (!def) return;
const N = !isFinite(def.days) ? total : Math.min(def.days, total);
setView({ start: total - N, end: total });
}
function clampWindow(start, end) {
let s = start, e = end;
if (s < 0) { e -= s; s = 0; }
if (e > total) { s -= (e - total); e = total; }
s = Math.max(0, Math.round(s));
e = Math.min(total, Math.round(e));
if (e - s < 2) e = Math.min(total, s + 2);
return { start: s, end: e };
}
// Which range pill (if any) matches the current window exactly + is end-anchored
const activeRange = (() => {
if (view.end !== total) return null;
const win = view.end - view.start;
for (const r of RANGE_DEFS) {
const target = !isFinite(r.days) ? total : Math.min(r.days, total);
if (win === target) return r.id;
}
return null;
})();
/* ── Pointer interaction handlers (mouse + touch + pen via Pointer Events) ── */
function onPointerDown(e) {
bodyRef.current?.setPointerCapture?.(e.pointerId);
pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
const n = pointersRef.current.size;
if (n === 1) {
const rect = bodyRef.current.getBoundingClientRect();
dragRef.current = { startX: e.clientX, startView: { ...view }, width: rect.width };
pinchRef.current = null;
} else if (n === 2) {
const [a, b] = [...pointersRef.current.values()];
pinchRef.current = {
startDist: Math.hypot(a.x - b.x, a.y - b.y),
startMidX: (a.x + b.x) / 2,
startView: { ...view },
rectLeft: bodyRef.current.getBoundingClientRect().left,
width: bodyRef.current.getBoundingClientRect().width,
};
dragRef.current = null;
}
setIsDragging(true);
}
function onPointerMove(e) {
if (!pointersRef.current.has(e.pointerId)) return;
pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
const n = pointersRef.current.size;
if (n === 1 && dragRef.current) {
const { startX, startView, width } = dragRef.current;
const dx = e.clientX - startX;
const win = startView.end - startView.start;
const dIdx = -(dx / width) * win;
setView(clampWindow(startView.start + dIdx, startView.end + dIdx));
} else if (n === 2 && pinchRef.current) {
const [a, b] = [...pointersRef.current.values()];
const dist = Math.hypot(a.x - b.x, a.y - b.y);
const { startDist, startMidX, startView, rectLeft, width } = pinchRef.current;
const scale = startDist / Math.max(1, dist); // >1 zooms out, <1 zooms in
const startWin = startView.end - startView.start;
const newWin = Math.max(2, Math.min(total, startWin * scale));
// Anchor zoom around the midpoint of the two fingers (in data-index space)
const midT = (startMidX - rectLeft) / width;
const midIdx = startView.start + midT * startWin;
setView(clampWindow(midIdx - midT * newWin, midIdx + (1 - midT) * newWin));
}
}
function onPointerUp(e) {
if (pointersRef.current.has(e.pointerId)) {
pointersRef.current.delete(e.pointerId);
}
if (pointersRef.current.size === 0) {
dragRef.current = null;
pinchRef.current = null;
setIsDragging(false);
} else if (pointersRef.current.size === 1) {
// Transition from pinch back to drag — restart drag anchor
const [pid] = [...pointersRef.current.keys()];
const pt = pointersRef.current.get(pid);
const rect = bodyRef.current.getBoundingClientRect();
dragRef.current = { startX: pt.x, startView: { ...view }, width: rect.width };
pinchRef.current = null;
}
}
function onWheel(e) {
// Only zoom on a PINCH gesture: a trackpad pinch (and Ctrl+scroll on a
// mouse) arrives as a wheel event with ctrlKey=true. A plain two-finger
// scroll has ctrlKey=false — let it through so the PAGE keeps scrolling
// instead of the chart hijacking it. preventDefault ONLY when we zoom.
if (!e.ctrlKey) return;
e.preventDefault();
const rect = bodyRef.current.getBoundingClientRect();
const cursorT = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const win = view.end - view.start;
const factor = e.deltaY > 0 ? 1.18 : 1 / 1.18;
const newWin = Math.max(2, Math.min(total, win * factor));
const cursorIdx = view.start + cursorT * win;
setView(clampWindow(cursorIdx - cursorT * newWin, cursorIdx + (1 - cursorT) * newWin));
}
// Attach as non-passive so preventDefault works on the pinch / Ctrl+scroll
// zoom path. Plain scroll falls through (onWheel early-returns above), so the
// page keeps scrolling when the cursor is over the chart.
useEffect(() => {
const el = bodyRef.current;
if (!el) return;
const handler = (e) => onWheel(e);
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
}, [view.start, view.end, total]);
/* ── Derived data slices for current view window ── */
const eqSlice = equityCurve.slice(view.start, view.end);
const pnlSlice = dailyPnl.slice(view.start, view.end);
const pnlPctSlice = pnlSlice.map(d => {
const tr = tradeByDate[d.date];
const stake = (tr && tr.stake_dollars) || 15000;
return { date: d.date, pnl_dollars: d.pnl_dollars, pct: stake ? (d.pnl_dollars / stake) * 100 : 0, stake };
});
const cumulativeSlice = (() => {
let sum = 0;
return pnlSlice.map(d => { sum += d.pnl_dollars; return { date: d.date, cumulative: sum }; });
})();
const lastEq = eqSlice[eqSlice.length - 1]?.portfolio_value;
const firstEq = eqSlice[0]?.portfolio_value;
// ─── Sub-line under tabs: summary of current tab+range ───
let summaryLeft = "";
let summaryRight = "";
if (tab === "portfolio") {
summaryLeft = `${eqSlice.length} trading days · daily close`;
summaryRight = `$${firstEq?.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} → $${lastEq?.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
} else if (tab === "daily") {
const wins = pnlSlice.filter(d => d.pnl_dollars > 0).length;
const losses = pnlSlice.filter(d => d.pnl_dollars < 0).length;
const net = pnlSlice.reduce((a, d) => a + d.pnl_dollars, 0);
summaryLeft = `${pnlSlice.length} days · ${wins}W / ${losses}L`;
summaryRight = `net ${fmtUSD(net, { showSign: true })}`;
} else if (tab === "dailypct") {
const winRows = pnlPctSlice.filter(d => d.pct > 0);
const lossRows = pnlPctSlice.filter(d => d.pct < 0);
const avgWin = winRows.length ? winRows.reduce((a, d) => a + d.pct, 0) / winRows.length : 0;
const avgLoss = lossRows.length ? lossRows.reduce((a, d) => a + d.pct, 0) / lossRows.length : 0;
summaryLeft = `avg win ${fmtPct(avgWin, 2)} · avg loss ${fmtPct(avgLoss, 2)}`;
summaryRight = `% of daily stake deployed`;
} else if (tab === "cumulative") {
const t = cumulativeSlice[cumulativeSlice.length - 1]?.cumulative ?? 0;
summaryLeft = `running sum · ${cumulativeSlice.length} days`;
summaryRight = `total ${fmtUSD(t, { showSign: true })}`;
}
// ─── Choose data + renderer per tab ───
let chartEl = null;
if (tab === "portfolio") {
chartEl = "$" + v.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
tooltipPrimaryLabel="EQUITY"
tooltipDeltaLabel="Δ START"
tradeByDate={tradeByDate}
tickerMeta={tickerMeta}
interactive={!isDragging}
accentColor={accentColor}
/>;
} else if (tab === "daily") {
chartEl = ({ ...d, value: d.pnl_dollars }))}
yFmt={fmtAxisUSD}
tooltipPrimary={v => fmtUSD(v, { showSign: true })}
tooltipPrimaryLabel="P&L"
tradeByDate={tradeByDate}
tickerMeta={tickerMeta}
interactive={!isDragging}
/>;
} else if (tab === "dailypct") {
chartEl = ({ ...d, value: d.pct }))}
yFmt={v => (v > 0 ? "+" : v < 0 ? "−" : "") + Math.abs(v).toFixed(1) + "%"}
tooltipPrimary={v => fmtPct(v, 2)}
tooltipPrimaryLabel="P&L %"
extraRows={(d) => [["STAKE", "$" + d.stake.toLocaleString()], ["P&L $", fmtUSD(d.pnl_dollars, { showSign: true })]]}
tradeByDate={tradeByDate}
tickerMeta={tickerMeta}
interactive={!isDragging}
/>;
} else if (tab === "cumulative") {
chartEl = fmtUSD(v, { showSign: true })}
tooltipPrimaryLabel="CUM P&L"
tradeByDate={tradeByDate}
tickerMeta={tickerMeta}
anchorAtZero={true}
interactive={!isDragging}
accentColor={accentColor}
/>;
}
return (
{TAB_DEFS.map(t => (
setTab(t.id)}
>
{t.label}
))}
{RANGE_DEFS.map(r => (
snapToRange(r.id)}
>{r.label}
))}
{summaryLeft}
{summaryRight} · drag to pan · pinch or Ctrl+scroll to zoom
{chartEl}
);
}
/* Grow a chart's height when its values span a large magnitude — mirrors the
strategy-builder's Market-history charts so the dashboard + demo charts auto-scale
the same way (item D). `ratio` = big magnitude ÷ small reference; H ∈ [baseH, ~1.9·baseH]. */
function adaptiveChartH(baseH, ratio) {
return Math.round(baseH * Math.min(1.9, 1 + Math.log10(Math.max(1, ratio)) * 0.55));
}
// Median of |v| over non-zero values — the "typical bar" reference for zero-crossing
// series (cumulative P&L, daily/Monthly bars): a lone blow-out then drives a tall chart.
function medianAbs(values) {
const a = values.map((v) => Math.abs(v)).filter((v) => v > 1e-9).sort((x, y) => x - y);
return a.length ? a[Math.floor(a.length / 2)] : 0;
}
// Compact $ y-axis label (mirrors the strategy-builder's fmtAxisUSD): big values
// abbreviate to K/M/B/T so a high-balance / long-compounding chart shows "$5.9M"
// instead of "$5900.0k". Axis labels only — tooltips keep full precision.
function fmtAxisUSD(n) {
const neg = n < 0, a = Math.abs(n);
const body = a >= 1e12 ? (a / 1e12).toFixed(a >= 1e13 ? 0 : 1).replace(/\.0$/, "") + "T"
: a >= 1e9 ? (a / 1e9).toFixed(a >= 1e10 ? 0 : 1).replace(/\.0$/, "") + "B"
: a >= 1e6 ? (a / 1e6).toFixed(a >= 1e7 ? 0 : 1).replace(/\.0$/, "") + "M"
: a >= 1e3 ? (a / 1e3).toFixed(a >= 1e4 ? 0 : 1).replace(/\.0$/, "") + "K"
: String(Math.round(a));
return (neg ? "-$" : "$") + body;
}
/* ───────── Generic line view (Portfolio / Cumulative) ───────── */
function LineView({ data, yKey, yFmt, tooltipPrimary, tooltipPrimaryLabel, tooltipDeltaLabel,
tradeByDate, tickerMeta, anchorAtZero = false, height = 300, interactive = true,
accentColor = "#cda85c" }) {
const wrapRef = useRef(null);
const [hover, setHover] = useState(null);
const [width, setWidth] = useState(900);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setWidth(el.clientWidth));
ro.observe(el);
setWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
useEffect(() => { if (!interactive) setHover(null); }, [interactive]);
// Window changed (zoom) → clear any stale hover so a later render can't index
// past the new, shorter data array (which would throw and blank the page).
useEffect(() => { setHover(null); }, [data.length]);
if (!data || data.length === 0) return
;
const W = width;
const values = data.map(d => d[yKey]);
let min = Math.min(...values);
let max = Math.max(...values);
if (anchorAtZero) {
min = Math.min(0, min);
max = Math.max(0, max);
}
// Auto-scale height (item D): a positive-only series (portfolio value) grows by
// max/min; a zero-crossing series (cumulative P&L) grows when one point dwarfs the
// typical |value|. Real accounts barely span a multiple → H stays ≈ base height.
const _ratio = (!anchorAtZero && min > 0)
? Math.abs(max) / Math.max(1e-6, min)
: Math.max(Math.abs(min), Math.abs(max), 1e-9) / (medianAbs(values) || Math.max(Math.abs(min), Math.abs(max), 1e-9));
const H = adaptiveChartH(height, _ratio);
const iw = W - 56 - 16;
// adaptive x-axis — date density/rotation shared via chart_axis.js (window.TTOAxis)
const ax = (typeof window !== "undefined" && window.TTOAxis)
? window.TTOAxis.xTicks(data.length, iw, { isDesktop: W > 640, dateAt: i => data[i].date })
: { ticks: [], yearTier: [], bottomPad: 28 };
const pad = { l: 56, r: 16, t: 8, b: Math.max(28, ax.bottomPad) };
const ih = H - pad.t - pad.b;
const yPadV = (max - min) * 0.1 || 1;
const yMin = min - yPadV;
const yMax = max + yPadV;
const x = i => pad.l + (data.length === 1 ? iw / 2 : (i / (data.length - 1)) * iw);
const y = v => pad.t + ih - ((v - yMin) / (yMax - yMin)) * ih;
const linePath = data.map((d, i) => (i === 0 ? "M" : "L") + x(i).toFixed(2) + "," + y(d[yKey]).toFixed(2)).join(" ");
const areaPath = linePath + ` L${x(data.length - 1).toFixed(2)},${pad.t + ih} L${x(0).toFixed(2)},${pad.t + ih} Z`;
const yTicks = 4;
const yVals = Array.from({ length: yTicks + 1 }, (_, i) => yMin + ((yMax - yMin) * i) / yTicks);
// Color (green for non-negative trend / positive zero-anchor, red for negative)
const lastVal = data[data.length - 1][yKey];
const lineColor = anchorAtZero
? (lastVal >= 0 ? accentColor : "#FF5C6F")
: accentColor;
const isNeg = anchorAtZero && lastVal < 0;
function onMove(e) {
if (!interactive) return;
const rect = e.currentTarget.getBoundingClientRect();
const px = e.clientX - rect.left;
const t = (px - pad.l) / iw;
const i = Math.max(0, Math.min(data.length - 1, Math.round(t * (data.length - 1))));
setHover({ i, x: x(i), y: y(data[i][yKey]) });
}
function onLeave() { setHover(null); }
const startVal = data[0][yKey];
// `|| null` guards a stale hover index that points past the (possibly shorter)
// data array after a zoom — without it, `hovered.date` below throws on render.
const hovered = hover ? (data[hover.i] || null) : null;
const hoveredTrade = hovered ? tradeByDate[hovered.date] : null;
const hoveredMeta = hoveredTrade ? tickerMeta[hoveredTrade.ticker] : null;
const tooltipDelta = hovered ? hovered[yKey] - startVal : 0;
return (
{yVals.map((v, i) => (
))}
{/* zero baseline */}
{anchorAtZero && yMin < 0 && yMax > 0 && (
)}
{yVals.map((v, i) => (
{yFmt(v)}
))}
{ax.ticks.map((t, k) => {
const anchor = k === 0 ? "start" : k === ax.ticks.length - 1 ? "end" : "middle";
return {t.label} ;
})}
{(ax.yearTier || []).map((t, k) => (
{t.label}
))}
{hover && (
)}
{hover && hovered && (
DATE {fmtDateShort(hovered.date)} · {new Date(hovered.date).getFullYear()}
{tooltipPrimaryLabel} = 0 ? "pnl-pos" : "pnl-neg") : "v"}>{tooltipPrimary(hovered[yKey])}
{tooltipDeltaLabel && (
{tooltipDeltaLabel} = 0 ? "pnl-pos" : "pnl-neg"}>{fmtUSD(tooltipDelta, { showSign: true })}
)}
{hoveredTrade && (
{hoveredTrade.ticker}{hoveredMeta ? " · " + hoveredMeta.n : ""}
{hoveredMeta && {hoveredMeta.s} · {hoveredMeta.x} }
)}
)}
);
}
/* ───────── Generic bars view (Daily $ / Daily %) ───────── */
function BarsView({ data, yFmt, tooltipPrimary, tooltipPrimaryLabel, extraRows,
tradeByDate, tickerMeta, height = 300, interactive = true }) {
const wrapRef = useRef(null);
const [width, setWidth] = useState(900);
const [hover, setHover] = useState(null);
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setWidth(el.clientWidth));
ro.observe(el);
setWidth(el.clientWidth);
return () => ro.disconnect();
}, []);
useEffect(() => { if (!interactive) setHover(null); }, [interactive]);
// Clear stale hover when the window changes (zoom), matching LineView.
useEffect(() => { setHover(null); }, [data.length]);
if (!data || data.length === 0) return
;
const W = width;
const max = Math.max(...data.map(d => Math.abs(d.value))) || 1;
// auto-scale height when one bar dwarfs the rest (item D) — same curve as the builder bars
const H = adaptiveChartH(height, max / (medianAbs(data.map(d => d.value)) || max));
const iw = W - 56 - 16;
const ax = (typeof window !== "undefined" && window.TTOAxis)
? window.TTOAxis.xTicks(data.length, iw, { isDesktop: W > 640, dateAt: i => data[i].date })
: { ticks: [], yearTier: [], bottomPad: 28 };
const pad = { l: 56, r: 16, t: 14, b: Math.max(28, ax.bottomPad) };
const ih = H - pad.t - pad.b;
const zero = pad.t + ih / 2;
const yScale = (ih / 2) / max;
// With very dense data (1000+ bars in ~800px) bars can go sub-pixel; clamp to 1.
const bw = Math.max(1, Math.min(20, (iw / data.length) - (data.length > 200 ? 0 : 2)));
const xOf = i => pad.l + (data.length === 1 ? iw / 2 : (i / (data.length - 1)) * iw);
const yTickVals = [max, max / 2, 0, -max / 2, -max];
const yTickY = v => zero - v * yScale;
const hovered = hover ? data[hover.i] : null;
const hoveredTrade = hovered ? tradeByDate[hovered.date] : null;
const hoveredMeta = hoveredTrade ? tickerMeta[hoveredTrade.ticker] : null;
return (
{yTickVals.map((v, i) => (
))}
{yTickVals.map((v, i) => (
{yFmt(v)}
))}
{ax.ticks.map((t, k) => {
const anchor = k === 0 ? "start" : k === ax.ticks.length - 1 ? "end" : "middle";
return {t.label} ;
})}
{(ax.yearTier || []).map((t, k) => (
{t.label}
))}
{data.map((d, i) => {
const xCenter = pad.l + (data.length === 1 ? iw / 2 : (i / (data.length - 1)) * iw);
const x = xCenter - bw / 2;
const v = d.value;
const h = Math.abs(v) * yScale;
const yTop = v >= 0 ? zero - h : zero;
const fill = v >= 0 ? "var(--pos)" : "var(--neg)";
const isHovered = hover && hover.i === i;
return (
interactive && setHover({ i, x: xCenter, yTop })}
onMouseLeave={() => interactive && setHover(null)}
/>
);
})}
{hover && (
)}
{hover && hovered && (
DATE {fmtDateShort(hovered.date)}
{tooltipPrimaryLabel} = 0 ? "pnl-pos" : "pnl-neg"}>{tooltipPrimary(hovered.value)}
{extraRows && extraRows(hovered).map(([k, v], idx) => (
{k} {v}
))}
{hoveredTrade && (
{hoveredTrade.ticker}{hoveredMeta ? " · " + hoveredMeta.n : ""}
{hoveredMeta && {hoveredMeta.s} · {hoveredMeta.x} }
)}
)}
);
}
/* ───────── Recent trades ───────── */
function TradesTable({ trades, tickerMeta }) {
const total = trades.length;
const INITIAL = 10;
const [shown, setShown] = useState(Math.min(INITIAL, total));
const [expansions, setExpansions] = useState(0);
// Progressive disclosure (bank-statement style): show 10, reveal +20 on the
// first expand and +50 on each subsequent one, with a "show all" jump and a
// "show less" collapse. Keeps a 100+ row history from dominating the page.
const remaining = total - shown;
const nextInc = expansions === 0 ? 20 : 50;
const stepLabel = Math.min(nextInc, remaining);
const showMore = () => { setShown(Math.min(shown + nextInc, total)); setExpansions(expansions + 1); };
const showAll = () => { setShown(total); setExpansions(expansions + 1); };
const showLess = () => { setShown(Math.min(INITIAL, total)); setExpansions(0); };
const visible = trades.slice(0, shown);
return (
Recent trades
showing {visible.length} of {total} closed positions · newest first
read-only · live data
Date
Strategy
Ticker
Company
Sector
Time Chosen
Time Chosen Price
Stake
Buy Time
Buy Price
Sell Time
Sell Price
P&L $
P&L %
{visible.map((t, i) => {
const meta = tickerMeta[t.ticker] || { n: t.ticker, s: "—", x: "" };
return (
{fmtDateShort(t.date)}
{t.strategy || "—"}
{t.ticker}
{meta.x && · {meta.x} }
{meta.n}
{meta.s}
${t.stake_dollars.toLocaleString()}
{fmtUSD(t.pnl_dollars, { showSign: true })}
{fmtPct(t.pnl_percent, 2)}
);
})}
{total > INITIAL && (
{shown < total ? (
<>
Show {stepLabel} more
Show all {total}
>
) : (
Show less
)}
)}
);
}
/* ───────── About / How it works ───────── */
function AboutSection({ strategy, acct }) {
const isB = acct && acct.strategyKind === "B";
const steps = [
{ time: "09:00", name: "Optimizer", desc: isB
? "No sizing search for Strategy B — stake is always the full portfolio. The optimizer still picks buy/sell timing."
: "Searches historical combinations to pick the day's buy/sell timing and stake sizing." },
{ time: "09:40", name: "Scanner", desc: "Pulls fresh top movers from a curated watchlist. Picks the top % gainer above the threshold for the next trading day's ticker." },
{ time: , name: "Buy", desc: isB
? "Deploys the ENTIRE portfolio into the picked ticker. Today's stake = yesterday's close."
: `Submits the buy order. Base stake = 10% of the account's settled cash ($${strategy.base_stake_dollars.toLocaleString()}), then scaled by the streak formula.` },
{ time: , name: "Sell", desc: "Closes the position at the chosen sell time. Realized P&L lands." },
{ time: "13:15", name: "Log", desc: "Records the day's run and updates the streak state for tomorrow." },
{ time: "13:30", name: "Notify", desc: "Emails you the day's summary." },
];
return (
How it works
{isB ? "Compound everything, every day." : "One stock, one day, one schedule."}
read-only · live data
Pick the strategy that fits your risk appetite — you can switch any time
you're not in a trade. Each strategy trades one stock per day :
every morning a scanner pulls the day's strongest pre-market movers from a
curated watchlist and picks the top gainer, and an optimizer decides when to
buy and sell based on what has worked historically.
Strategy A — Streak-scaled. {" "}
Deploys a base stake and scales it up during winning streaks, easing back
after a loss. Steadier growth with smaller drawdowns.
Strategy B — Full compounding. {" "}
Reinvests the entire balance into each day's pick, so gains and losses
compound together. Higher upside, bigger swings.
This account is running Strategy {isB ? "B — Full compounding" : "A — Streak-scaled"} .
Backtested over the last {strategy.backtest_days} trading days it returned
{" "}+{strategy.backtest_pnl_percent.toFixed(2)}%
{" "}at a {(strategy.backtest_win_rate * 100).toFixed(1)}% win rate.
{steps.map((s, i) => (
{s.time} ET
{s.name}
{s.desc}
))}
);
}
/* ───────── Export ───────── */
Object.assign(window, {
Sparkline, Topbar, TodayHero, StatsRow, MainChart, TradesTable, AboutSection,
AccountTabs, botState,
fmtUSD, fmtPct, fmtDateShort, pnlClass,
});