learning_ai_invt_trdg/web/src/tabs/OverviewTab.tsx

1062 lines
56 KiB
TypeScript

import { useState, useEffect, useMemo } from 'react';
import type { BotState } from '../hooks/useWebSocket';
import { useAuth } from '../components/AuthContext';
import { aggregateCanonicalLifecycleTrades } from '../lib/orderLifecycleLedger';
import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle';
import { fetchTradeProfiles } from '../lib/profileApi';
import { Button } from '../components/ui/Primitives';
interface OverviewTabProps {
botState: BotState;
previewAsCustomer?: boolean;
connected?: boolean;
}
interface ActiveProfileCapital {
id: string;
name: string;
allocated: number;
cooldownMinutes: number;
}
interface TradeAggregate {
tradeCount: number;
wins: number;
winRate: number;
netPnl: number;
lastClosedTradeAt: number;
}
interface ProfileSignalAggregate {
totalSignals: number;
activeSignals: number;
blockedSignals: number;
skippedSignals: number;
executedSignals: number;
}
type WinRateWindow = '24h' | '7d' | '30d' | 'all';
const WIN_RATE_WINDOW_OPTIONS: Array<{ key: WinRateWindow; label: string; ms?: number }> = [
{ key: '24h', label: '24H', ms: 24 * 60 * 60 * 1000 },
{ key: '7d', label: '7D', ms: 7 * 24 * 60 * 60 * 1000 },
{ key: '30d', label: '30D', ms: 30 * 24 * 60 * 60 * 1000 },
{ key: 'all', label: 'All' }
];
const REFRESH_INTERVAL_MS = 30_000;
const overviewWarningBorder = '1px solid color-mix(in oklab, var(--bl-warning) 28%, transparent)';
const overviewWarningSurface = 'var(--bl-warning-muted)';
const overviewWarningText = 'var(--bl-warning)';
const overviewDangerBorder = '1px solid color-mix(in oklab, var(--bl-danger) 28%, transparent)';
const overviewDangerSurface = 'var(--bl-danger-muted)';
const overviewDangerText = 'var(--bl-danger)';
const overviewMutedText = 'var(--bl-text-faint)';
const overviewQuietText = 'var(--bl-text-quiet)';
const overviewInfoText = 'var(--bl-info-strong)';
const overviewAttentionText = 'var(--bl-warning)';
const overviewSuccessText = 'var(--bl-success)';
const overviewNeutralText = 'var(--foreground)';
const overviewSoftBorder = '1px solid var(--bl-border-subtle)';
const overviewActiveBorder = '1px solid color-mix(in oklab, var(--bl-success) 45%, transparent)';
const overviewActiveSurface = 'color-mix(in oklab, var(--bl-success) 8%, transparent)';
const overviewMutedSurface = 'color-mix(in oklab, var(--foreground) 3%, transparent)';
const overviewSectionBorder = '1px solid color-mix(in oklab, var(--bl-border) 70%, transparent)';
const overviewCardGradient = 'linear-gradient(145deg, color-mix(in oklab, var(--bl-surface-overlay) 92%, var(--card-elevated)), color-mix(in oklab, var(--bl-surface-overlay) 84%, var(--background)))';
const overviewCardBorder = '1px solid color-mix(in oklab, var(--bl-border) 55%, transparent)';
const overviewDivider = '1px solid color-mix(in oklab, var(--bl-border) 45%, transparent)';
const overviewBotMuted = 'color-mix(in oklab, var(--muted-foreground) 62%, var(--background))';
const overviewBotQuiet = 'color-mix(in oklab, var(--muted-foreground) 52%, var(--background))';
const overviewTagBorder = '1px solid color-mix(in oklab, var(--bl-border) 82%, transparent)';
const formatUptime = (ms: number): string => {
if (ms < 60000) return `${Math.floor(ms / 1000)}s`;
if (ms < 3600000) return `${Math.floor(ms / 60000)}m`;
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
return `${hours}h ${mins}m`;
};
const formatDurationCompact = (ms: number): string => {
if (!Number.isFinite(ms) || ms <= 0) return '0m';
const totalMinutes = Math.max(0, Math.floor(ms / 60000));
const days = Math.floor(totalMinutes / 1440);
const hours = Math.floor((totalMinutes % 1440) / 60);
const minutes = totalMinutes % 60;
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
};
const formatUsd = (value: number): string =>
`$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const compactTag = (value?: string): string => {
const token = String(value || '').trim();
if (!token) return '-';
return token.length > 24 ? `${token.slice(0, 12)}...${token.slice(-8)}` : token;
};
export const dedupeLivePositions = (positions: BotState['positions'] = []): BotState['positions'] => {
const mergedByKey = new Map<string, BotState['positions'][0]>();
const score = (position: BotState['positions'][0]): number => {
const tradeScore = position.tradeId ? 4 : 0;
const profileScore = position.profileId ? 3 : 0;
const userScore = position.profileName ? 1 : 0;
const notional = Math.abs(Number(position.entryPrice || 0) * Number(position.size || 0));
return tradeScore + profileScore + userScore + Math.min(notional, 100_000);
};
for (const position of positions || []) {
const tradeId = String(position.tradeId || '').trim();
const key = tradeId
? `trade:${tradeId}`
: `fallback:${position.profileId || 'global'}|${position.symbol}|${position.side}`;
const existing = mergedByKey.get(key);
if (!existing) {
mergedByKey.set(key, position);
continue;
}
const preferred = score(position) >= score(existing) ? position : existing;
const fallback = preferred === position ? existing : position;
mergedByKey.set(key, {
...fallback,
...preferred,
profileId: preferred.profileId || fallback.profileId,
profileName: preferred.profileName || fallback.profileName,
tradeId: preferred.tradeId || fallback.tradeId
});
}
return Array.from(mergedByKey.values());
};
export const OverviewTab = ({ botState, previewAsCustomer = false, connected = true }: OverviewTabProps) => {
const { user, profile } = useAuth();
const {
snapshot: canonicalSnapshot,
loading: canonicalLoading,
error: canonicalError
} = useCanonicalLifecycle();
const isAdminView = profile?.role === 'admin' && !previewAsCustomer;
const symbols = Object.keys(botState.symbols || {});
const [fallbackCapital, setFallbackCapital] = useState(botState.settings.totalCapital);
const [profileCount, setProfileCount] = useState(0);
const [activeProfiles, setActiveProfiles] = useState<ActiveProfileCapital[]>([]);
const [winRateWindow, setWinRateWindow] = useState<WinRateWindow>('7d');
const canonicalLifecycleReady = Boolean(canonicalSnapshot && !canonicalSnapshot.diagnostics?.truncated);
useEffect(() => {
if (!user) return;
let cancelled = false;
const loadStats = async () => {
if (cancelled) return;
try {
const profilesData = await fetchTradeProfiles({ scope: profile?.role === 'admin' ? 'all' : 'user' });
const activeProfileRows: ActiveProfileCapital[] = ((profilesData as any[]) || [])
.filter((p: any) => Boolean(p.is_active))
.map((p: any) => {
const rawCooldown = Number(p?.strategy_config?.execution?.cooldownMinutes);
return {
id: String(p.id),
name: String(p.name || p.id),
allocated: Number(p.allocated_capital || 0),
cooldownMinutes: Number.isFinite(rawCooldown) && rawCooldown >= 0 ? rawCooldown : 30
};
});
const activeCapital = activeProfileRows.reduce((sum, p) => sum + p.allocated, 0);
if (cancelled) return;
setFallbackCapital(activeCapital || botState.settings.totalCapital);
setProfileCount(activeProfileRows.length);
setActiveProfiles(activeProfileRows);
} catch (err) {
console.error('[OverviewTab] Unexpected load error:', err);
}
};
loadStats();
const refreshTimer = window.setInterval(loadStats, REFRESH_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(refreshTimer);
};
}, [user, profile?.role, botState.settings.totalCapital]);
const lifecycleOpenByProfile = useMemo(() => {
if (canonicalLifecycleReady && canonicalSnapshot) {
const usedByProfile = new Map<string, number>();
const unrealizedByProfile = new Map<string, number>();
const openTradesByProfile = new Map<string, number>();
for (const [profileId, aggregate] of Object.entries(canonicalSnapshot.aggregates.byProfile || {})) {
const openNotional = Number(aggregate.openNotional || 0);
const unrealizedPnl = Number(aggregate.unrealizedPnl || 0);
const openTrades = Number(aggregate.openTrades || 0);
if (openNotional > 0) usedByProfile.set(profileId, openNotional);
if (Math.abs(unrealizedPnl) > 0) unrealizedByProfile.set(profileId, unrealizedPnl);
if (openTrades > 0) openTradesByProfile.set(profileId, openTrades);
}
return {
hasRows: true,
usedByProfile,
unrealizedByProfile,
openTradesByProfile
};
}
return {
hasRows: false,
usedByProfile: new Map<string, number>(),
unrealizedByProfile: new Map<string, number>(),
openTradesByProfile: new Map<string, number>()
};
}, [canonicalLifecycleReady, canonicalSnapshot]);
const liveOpenByProfile = useMemo(() => {
const usedByProfile = new Map<string, number>();
const unrealizedByProfile = new Map<string, number>();
const openTradesByProfile = new Map<string, number>();
const deduped = dedupeLivePositions(botState.positions || []);
for (const position of deduped) {
const profileId = String(position.profileId || '').trim();
if (!profileId) continue;
const notional = Math.max(0, Math.abs(Number(position.entryPrice || 0) * Number(position.size || 0)));
const unrealized = Number(position.unrealizedPnl || 0);
usedByProfile.set(profileId, (usedByProfile.get(profileId) || 0) + notional);
unrealizedByProfile.set(profileId, (unrealizedByProfile.get(profileId) || 0) + unrealized);
openTradesByProfile.set(profileId, (openTradesByProfile.get(profileId) || 0) + 1);
}
return {
hasRows: deduped.length > 0,
usedByProfile,
unrealizedByProfile,
openTradesByProfile
};
}, [botState.positions]);
const effectiveOpenByProfile = useMemo(() => {
const usedByProfile = new Map<string, number>(lifecycleOpenByProfile.usedByProfile);
const unrealizedByProfile = new Map<string, number>(lifecycleOpenByProfile.unrealizedByProfile);
const openTradesByProfile = new Map<string, number>(lifecycleOpenByProfile.openTradesByProfile);
const suppressedLifecycleNotionalByProfile = new Map<string, number>();
// If lifecycle still claims open notional while live inventory is flat, prefer live 0 usage
// to avoid stale capital-lock visuals in Overview while reconciliation catches up.
for (const [profileId, canonicalUsedRaw] of usedByProfile.entries()) {
const canonicalUsed = Math.max(0, Number(canonicalUsedRaw || 0));
if (!(canonicalUsed > 1e-8)) continue;
const canonicalOpenCount = Number(openTradesByProfile.get(profileId) || 0);
const liveUsed = Math.max(0, Number(liveOpenByProfile.usedByProfile.get(profileId) || 0));
const liveOpenCount = Number(liveOpenByProfile.openTradesByProfile.get(profileId) || 0);
if (canonicalOpenCount <= 0) continue;
if (liveOpenCount <= 0 && !(liveUsed > 1e-8)) {
suppressedLifecycleNotionalByProfile.set(profileId, canonicalUsed);
usedByProfile.set(profileId, 0);
openTradesByProfile.set(profileId, 0);
unrealizedByProfile.delete(profileId);
}
}
for (const [profileId, liveUsed] of liveOpenByProfile.usedByProfile.entries()) {
const canonicalUsed = Number(usedByProfile.get(profileId) || 0);
if (!(canonicalUsed > 0)) {
usedByProfile.set(profileId, liveUsed);
}
}
for (const [profileId, liveUnrealized] of liveOpenByProfile.unrealizedByProfile.entries()) {
const canonicalUnrealized = Number(unrealizedByProfile.get(profileId) || 0);
if (Math.abs(canonicalUnrealized) <= 1e-8) {
unrealizedByProfile.set(profileId, liveUnrealized);
}
}
for (const [profileId, liveOpenCount] of liveOpenByProfile.openTradesByProfile.entries()) {
const canonicalOpenCount = Number(openTradesByProfile.get(profileId) || 0);
if (liveOpenCount > canonicalOpenCount) {
openTradesByProfile.set(profileId, liveOpenCount);
}
}
return {
hasRows: lifecycleOpenByProfile.hasRows || liveOpenByProfile.hasRows,
usedByProfile,
unrealizedByProfile,
openTradesByProfile,
suppressedLifecycleNotionalByProfile
};
}, [lifecycleOpenByProfile, liveOpenByProfile]);
const openPositionsByProfile = useMemo(() => {
return effectiveOpenByProfile.openTradesByProfile;
}, [effectiveOpenByProfile]);
const profileSignalsByProfile = useMemo(() => {
const byProfile = new Map<string, ProfileSignalAggregate>();
for (const symbol of Object.keys(botState.symbols || {})) {
const symbolState = botState.symbols[symbol];
const entries = Object.entries(symbolState?.profileSignals || {});
for (const [profileId, signalState] of entries) {
const current = byProfile.get(profileId) || {
totalSignals: 0,
activeSignals: 0,
blockedSignals: 0,
skippedSignals: 0,
executedSignals: 0
};
current.totalSignals += 1;
const signal = String((signalState as any)?.signal || '').toUpperCase();
const passed = Boolean((signalState as any)?.passed);
const directional = signal === 'BUY' || signal === 'SELL';
const executionStatus = String((signalState as any)?.execution?.status || '').toUpperCase();
if (passed && directional) {
if (executionStatus === 'BLOCKED') {
current.blockedSignals += 1;
} else if (executionStatus === 'SKIPPED') {
current.skippedSignals += 1;
} else {
current.activeSignals += 1;
if (executionStatus === 'EXECUTED') {
current.executedSignals += 1;
}
}
}
byProfile.set(profileId, current);
}
}
return byProfile;
}, [botState.symbols]);
const canonicalLifecycleTrades = useMemo(() => {
if (canonicalLifecycleReady && canonicalSnapshot) {
return canonicalSnapshot.realizedTrades.map((trade) => ({
id: trade.id,
tradeId: trade.tradeId,
profileId: trade.profileId,
symbol: trade.symbol,
side: trade.side,
size: Number(trade.size || 0),
entryPrice: Number(trade.entryPrice || 0),
exitPrice: Number(trade.exitPrice || 0),
pnl: Number(trade.pnl || 0),
pnlPercent: Number(trade.pnlPercent || 0),
closedAtMs: Number(trade.closedAt || 0)
}));
}
return [];
}, [canonicalLifecycleReady, canonicalSnapshot]);
const hasCanonicalLifecyclePnl = canonicalLifecycleReady;
const canonicalAggregate = useMemo(
() => aggregateCanonicalLifecycleTrades(canonicalLifecycleTrades),
[canonicalLifecycleTrades]
);
const winRateWindowConfig = useMemo(
() => WIN_RATE_WINDOW_OPTIONS.find((option) => option.key === winRateWindow) || WIN_RATE_WINDOW_OPTIONS[1],
[winRateWindow]
);
const canonicalWindowTrades = useMemo(() => {
if (!winRateWindowConfig.ms) return canonicalLifecycleTrades;
const cutoff = Date.now() - winRateWindowConfig.ms;
return canonicalLifecycleTrades.filter((trade) => Number(trade.closedAtMs || 0) >= cutoff);
}, [canonicalLifecycleTrades, winRateWindowConfig.ms]);
const canonicalWindowAggregate = useMemo(
() => aggregateCanonicalLifecycleTrades(canonicalWindowTrades),
[canonicalWindowTrades]
);
const displayRealizedPnl = hasCanonicalLifecyclePnl
? canonicalAggregate.totalPnl
: 0;
const displayWindowAggregate = hasCanonicalLifecyclePnl
? canonicalWindowAggregate
: { totalPnl: 0, tradeCount: 0, wins: 0, winRate: 0, byProfile: {} as Record<string, TradeAggregate> };
const pnlWindow = useMemo(() => {
if (hasCanonicalLifecyclePnl) {
const timestamps = canonicalLifecycleTrades
.map((trade) => Number(trade.closedAtMs || 0))
.filter((ts) => Number.isFinite(ts) && ts > 0);
if (timestamps.length === 0) {
return {
durationLabel: '-',
fromTs: 0,
toTs: 0
};
}
const fromTs = Math.min(...timestamps);
const toTs = Math.max(...timestamps);
return {
durationLabel: formatDurationCompact(Math.max(0, toTs - fromTs)),
fromTs,
toTs
};
}
return {
durationLabel: '-',
fromTs: 0,
toTs: 0
};
}, [canonicalLifecycleTrades, hasCanonicalLifecyclePnl]);
const profileTradeStats = useMemo(() => {
const aggregates: Record<string, TradeAggregate> = {};
const source = hasCanonicalLifecyclePnl ? canonicalAggregate.byProfile : {};
for (const [profileId, agg] of Object.entries(source)) {
aggregates[profileId] = {
tradeCount: agg.tradeCount,
wins: agg.wins,
winRate: agg.winRate,
netPnl: agg.realizedPnl,
lastClosedTradeAt: agg.lastClosedTradeAt
};
}
return aggregates;
}, [canonicalAggregate.byProfile, hasCanonicalLifecyclePnl]);
const profileWindowStats = useMemo(() => {
const aggregates: Record<string, { tradeCount: number; winRate: number }> = {};
for (const [profileId, agg] of Object.entries(displayWindowAggregate.byProfile)) {
aggregates[profileId] = {
tradeCount: agg.tradeCount,
winRate: agg.winRate
};
}
return aggregates;
}, [displayWindowAggregate.byProfile]);
const unrealizedPnl = useMemo(() => {
if (!effectiveOpenByProfile.hasRows) return 0;
return Array.from(effectiveOpenByProfile.unrealizedByProfile.values()).reduce((sum, value) => sum + value, 0);
}, [effectiveOpenByProfile]);
const netPnl = displayRealizedPnl + unrealizedPnl;
const profileCapitalRows = useMemo(() => {
const usedByProfile = effectiveOpenByProfile.usedByProfile;
const suppressedLifecycleNotionalByProfile = effectiveOpenByProfile.suppressedLifecycleNotionalByProfile;
const now = Date.now();
return activeProfiles.map((profileRow) => {
const allocated = Math.max(0, Number(profileRow.allocated || 0));
const rawUsed = Math.max(0, Number(usedByProfile.get(profileRow.id) || 0));
const suppressedLifecycleNotional = Math.max(0, Number(suppressedLifecycleNotionalByProfile?.get(profileRow.id) || 0));
const used = Math.min(rawUsed, allocated);
const overAllocatedAmount = Math.max(0, rawUsed - allocated);
const remaining = Math.max(0, allocated - used);
const utilizationPct = allocated > 0 ? (used / allocated) * 100 : 0;
const tradeStats = profileTradeStats[profileRow.id] || {
tradeCount: 0,
wins: 0,
winRate: 0,
netPnl: 0,
lastClosedTradeAt: 0
};
const windowStats = profileWindowStats[profileRow.id] || {
tradeCount: 0,
winRate: 0
};
const openCount = openPositionsByProfile.get(profileRow.id) || 0;
const signalStats = profileSignalsByProfile.get(profileRow.id) || {
totalSignals: 0,
activeSignals: 0,
blockedSignals: 0,
skippedSignals: 0,
executedSignals: 0
};
const cooldownMs = Math.max(0, Number(profileRow.cooldownMinutes || 0)) * 60_000;
let cooldownRemainingMs = 0;
if (openCount === 0 && cooldownMs > 0 && tradeStats.lastClosedTradeAt > 0) {
const elapsed = now - tradeStats.lastClosedTradeAt;
if (elapsed < cooldownMs) {
cooldownRemainingMs = cooldownMs - elapsed;
}
}
let runtimeState = 'Monitoring (no signal)';
let runtimeDetail = 'Rules are running; waiting for entry setup.';
let runtimeTone: 'running' | 'cooldown' | 'armed' | 'blocked' | 'idle' = 'idle';
if (suppressedLifecycleNotional > 1e-8) {
runtimeState = 'Lifecycle sync pending';
runtimeDetail = `Canonical lifecycle still reports ${formatUsd(suppressedLifecycleNotional)} open notional, but live inventory is flat. Utilization is temporarily based on live state.`;
runtimeTone = 'blocked';
} else if (overAllocatedAmount > 1e-8) {
runtimeState = 'Capital sync warning';
runtimeDetail = `Raw open-position notional is ${formatUsd(overAllocatedAmount)} above allocated capital. Showing capped utilization for clarity.`;
runtimeTone = 'armed';
} else if (openCount > 0) {
runtimeState = `In Position (${openCount} open)`;
runtimeDetail = 'Capital is deployed in active open positions.';
runtimeTone = 'running';
} else if (cooldownRemainingMs > 0) {
runtimeState = `Cooldown (wake up in ${Math.ceil(cooldownRemainingMs / 60000)}m)`;
runtimeDetail = 'Recent close detected; profile is waiting for cooldown expiry.';
runtimeTone = 'cooldown';
} else if (signalStats.blockedSignals > 0 && signalStats.activeSignals === 0) {
runtimeState = 'Signal blocked by guard';
runtimeDetail = 'Directional setup exists, but execution guards blocked entry (pause/capital/risk/cooldown).';
runtimeTone = 'blocked';
} else if (signalStats.executedSignals > 0 && signalStats.activeSignals > 0) {
runtimeState = 'Entry submitted, awaiting fill';
runtimeDetail = 'Signal executed recently and waiting for broker fill confirmation.';
runtimeTone = 'armed';
} else if (signalStats.activeSignals > 0) {
if (profileRow.allocated <= 0) {
runtimeState = 'Signal active, no allocation';
runtimeDetail = 'Allocated capital is 0. Increase allocation to place entries.';
} else if (remaining <= 1e-8) {
runtimeState = 'Signal active, capital full';
runtimeDetail = 'No free capital remains in this profile right now.';
} else {
runtimeState = 'Signal active, waiting entry';
runtimeDetail = 'No filled entry yet. Utilization will increase once an entry fills.';
}
runtimeTone = 'armed';
}
return {
...profileRow,
allocated,
rawUsed,
used,
overAllocatedAmount,
remaining,
utilizationPct,
winRate: windowStats.winRate,
tradeCount: windowStats.tradeCount,
netPnl: tradeStats.netPnl,
runtimeState,
runtimeDetail,
runtimeTone
};
});
}, [
activeProfiles,
effectiveOpenByProfile,
profileSignalsByProfile,
profileTradeStats,
profileWindowStats,
openPositionsByProfile
]);
const allocatedCapital = useMemo(() => {
if (profileCapitalRows.length > 0) {
return profileCapitalRows.reduce((sum, row) => sum + row.allocated, 0);
}
return Number(fallbackCapital || 0);
}, [profileCapitalRows, fallbackCapital]);
const capitalUsed = useMemo(() => {
if (profileCapitalRows.length > 0) {
return profileCapitalRows.reduce((sum, row) => sum + row.used, 0);
}
return 0;
}, [profileCapitalRows]);
const rawCapitalUsed = useMemo(() => {
if (profileCapitalRows.length > 0) {
return profileCapitalRows.reduce((sum, row) => sum + row.rawUsed, 0);
}
return 0;
}, [profileCapitalRows]);
const overAllocatedCapital = Math.max(0, rawCapitalUsed - capitalUsed);
const remainingCapital = Math.max(0, allocatedCapital - capitalUsed);
const canonicalUnavailable = !canonicalLifecycleReady || !!canonicalError;
return (
<div className="overview-tab">
<div className="tab-header">
<h2>Market Readiness</h2>
<p>Global view of market conditions and bot status.</p>
</div>
{canonicalUnavailable && (
<div style={{
marginBottom: '12px',
padding: '8px 10px',
borderRadius: '8px',
border: overviewWarningBorder,
background: overviewWarningSurface,
color: overviewWarningText,
fontSize: '0.75rem'
}}>
Canonical lifecycle is unavailable{canonicalLoading ? ' (loading)' : ''}. Showing fallback values from DB/runtime sources.
{canonicalError ? ` ${canonicalError}` : ''}
</div>
)}
{canonicalSnapshot?.diagnostics?.truncated && (
<div style={{
marginBottom: '12px',
padding: '8px 10px',
borderRadius: '8px',
border: overviewDangerBorder,
background: overviewDangerSurface,
color: overviewDangerText,
fontSize: '0.75rem'
}}>
Canonical lifecycle snapshot is truncated ({canonicalSnapshot.diagnostics.orderRows} rows). Narrow scope before using this for operational decisions.
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.72rem', color: overviewMutedText, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Win Rate Window:
</span>
{WIN_RATE_WINDOW_OPTIONS.map((option) => {
const active = option.key === winRateWindow;
return (
<Button
type="button"
key={option.key}
onClick={() => setWinRateWindow(option.key)}
variant={active ? 'secondary' : 'ghost'}
size="sm"
style={{
border: active ? overviewActiveBorder : overviewSoftBorder,
color: active ? overviewSuccessText : overviewQuietText,
background: active ? overviewActiveSurface : overviewMutedSurface,
borderRadius: '8px',
fontSize: '0.7rem',
fontWeight: 700
}}
>
{option.label}
</Button>
);
})}
</div>
<div className="bot-status-bar">
<div className="status-item">
<span className="label">System:</span>
{(() => {
const mode = botState?.health?.tradingControl?.mode;
const wsConnected = connected;
if (!wsConnected) return <span className="value status-offline">DISCONNECTED</span>;
if (mode === 'PAUSED') return <span className="value status-paused">PAUSED</span>;
if (mode === 'RUNNING' || !mode) return <span className="value status-online">RUNNING</span>;
return <span className="value text-amber-500">{mode}</span>;
})()}
</div>
<div className="status-item">
<span className="label">Mode:</span>
<span className={`value mode-${botState.settings.executionMode.toLowerCase()}`}>
{botState.settings.executionMode}
</span>
</div>
<div className="status-item">
<span className="label">Allocated:</span>
<span className="value">{formatUsd(allocatedCapital)}</span>
{profileCount > 0 && <span style={{ fontSize: '0.65rem', color: overviewQuietText, marginLeft: '4px' }}>({profileCount} profiles)</span>}
</div>
<div className="status-item">
<span className="label">Capital Used:</span>
<span className="value" style={{ color: overviewInfoText }}>{formatUsd(capitalUsed)}</span>
{overAllocatedCapital > 1e-8 && (
<span style={{ fontSize: '0.65rem', color: overviewAttentionText }}>
capped (+{formatUsd(overAllocatedCapital)} raw)
</span>
)}
</div>
<div className="status-item">
<span className="label">Remaining:</span>
<span className={`value ${overAllocatedCapital > 1e-8 ? 'status-offline' : 'status-online'}`}>
{formatUsd(remainingCapital)}
</span>
</div>
<div className="status-item">
<span className="label">Uptime:</span>
<span className="value">{formatUptime(botState.uptime)}</span>
</div>
<div className="status-item">
<span className="label">Realized P&L (90d):</span>
<span className={`value ${displayRealizedPnl >= 0 ? 'status-online' : 'status-offline'}`}>
{formatUsd(displayRealizedPnl)}
</span>
</div>
<div className="status-item">
<span className="label">Net P&L (90d):</span>
<span className={`value ${netPnl >= 0 ? 'status-online' : 'status-offline'}`}>
{formatUsd(netPnl)}
</span>
</div>
<div className="status-item">
<span className="label">Win Rate ({winRateWindowConfig.label}):</span>
<span className="value" style={{ color: displayWindowAggregate.winRate >= 50 ? overviewSuccessText : overviewWarningText }}>
{displayWindowAggregate.winRate.toFixed(1)}%
</span>
<span style={{ fontSize: '0.65rem', color: overviewMutedText }}>
{displayWindowAggregate.tradeCount} trades
</span>
</div>
<div className="status-item">
<span className="label">P&L Duration:</span>
<span
className="value"
title={pnlWindow.fromTs > 0
? `${new Date(pnlWindow.fromTs).toLocaleString()} -> ${new Date(pnlWindow.toTs).toLocaleString()}`
: 'No realized trades yet'}
>
{pnlWindow.durationLabel}
</span>
<span style={{ fontSize: '0.65rem', color: overviewMutedText }}>
{pnlWindow.fromTs > 0
? `From ${new Date(pnlWindow.fromTs).toLocaleDateString()}`
: 'Awaiting trade history'}
</span>
</div>
</div>
{/* --- NEW: Alpaca Account Health --- */}
<div className="bot-status-bar" style={{ marginTop: '16px', borderTop: overviewSectionBorder, paddingTop: '16px' }}>
<div className="status-item">
<span className="label">Broker Balance (Alpaca):</span>
<span className={`value ${botState.accountSnapshot ? 'status-online' : 'status-offline'}`}>
{botState.accountSnapshot
? formatUsd(botState.accountSnapshot.buying_power)
: 'Waiting for snapshot...'}
</span>
{botState.accountSnapshot && (
<span style={{ fontSize: '0.65rem', color: overviewMutedText }}>
Cash: {formatUsd(botState.accountSnapshot.cash)} ({botState.accountSnapshot.currency})
</span>
)}
</div>
{botState.accountSnapshot && (
<div className="status-item">
<span className="label">Last Sync:</span>
<span className="value">
{new Date(botState.accountSnapshot.timestamp).toLocaleTimeString()}
</span>
</div>
)}
{/* Parity & Self-Healing Heartbeat */}
<div className="status-item" style={{ borderLeft: overviewSoftBorder, paddingLeft: '16px' }}>
<span className="label">Parity Heartbeat:</span>
<span className={`value ${botState.health?.reconciliationParityMismatchTrades ? 'status-offline' : 'status-online'}`}>
{botState.health?.reconciliationParityMismatchTrades || 0} Mismatches
</span>
{Number(botState.health?.reconciliationParityAutoClosedTrades || 0) > 0 && (
<span style={{ fontSize: '0.65rem', color: overviewSuccessText, marginLeft: '4px' }}>
({botState.health?.reconciliationParityAutoClosedTrades} Self-Healed)
</span>
)}
{Number(botState.health?.reconciliationParityQuarantinedTrades || 0) > 0 && (
<span style={{ fontSize: '0.65rem', color: overviewWarningText, marginLeft: '4px' }}>
({botState.health?.reconciliationParityQuarantinedTrades} Quarantined)
</span>
)}
</div>
{/* Recent Failures Summary */}
{(botState.orderFailures || []).length > 0 && (
<div className="status-item" style={{ marginLeft: 'auto', borderLeft: overviewSoftBorder, paddingLeft: '16px' }}>
<span className="label" style={{ color: overviewDangerText }}>Recent Rejections:</span>
<span className="value" style={{ color: overviewDangerText }}>{(botState.orderFailures || []).length}</span>
<span style={{ fontSize: '0.65rem', color: overviewMutedText }}>
Latest: {(botState.orderFailures || [])[0].symbol} ({(botState.orderFailures || [])[0].reason?.substring(0, 20)}...)
</span>
</div>
)}
</div>
<div className="table-container" style={{ marginBottom: '32px' }}>
<table className="pro-table w-full">
<thead>
<tr>
<th>Capital Scope</th>
<th style={{ textAlign: 'right' }}>Allocated</th>
<th style={{ textAlign: 'right' }}>Used</th>
<th style={{ textAlign: 'right' }}>Remaining</th>
<th style={{ textAlign: 'right' }}>Utilization</th>
<th style={{ textAlign: 'right' }}>Win Rate ({winRateWindowConfig.label})</th>
<th style={{ textAlign: 'right' }}>Realized P&L</th>
<th>State</th>
</tr>
</thead>
<tbody>
{profileCapitalRows.length > 0 ? (
profileCapitalRows.map((row) => {
const overAllocated = row.overAllocatedAmount > 1e-8;
const stateColor = row.runtimeTone === 'running'
? overviewSuccessText
: row.runtimeTone === 'cooldown'
? overviewWarningText
: row.runtimeTone === 'armed'
? overviewInfoText
: row.runtimeTone === 'blocked'
? overviewDangerText
: overviewQuietText;
return (
<tr key={row.id}>
<td style={{ fontWeight: 700 }}>{row.name}</td>
<td style={{ textAlign: 'right' }}>{formatUsd(row.allocated)}</td>
<td style={{ textAlign: 'right', color: overviewInfoText }}>{formatUsd(row.used)}</td>
<td style={{ textAlign: 'right', color: overAllocated ? overviewDangerText : overviewSuccessText }}>
{formatUsd(row.remaining)}
{overAllocated && (
<div style={{ color: overviewAttentionText, fontSize: '0.65rem' }}>
raw +{formatUsd(row.overAllocatedAmount)}
</div>
)}
</td>
<td style={{ textAlign: 'right', color: overAllocated ? overviewDangerText : overviewNeutralText }}>
{row.utilizationPct.toFixed(1)}%
</td>
<td style={{ textAlign: 'right', color: row.winRate >= 50 ? overviewSuccessText : overviewWarningText }}>
{row.winRate.toFixed(1)}%
<span style={{ color: overviewMutedText, marginLeft: '4px', fontSize: '0.65rem' }}>
({row.tradeCount})
</span>
</td>
<td style={{ textAlign: 'right', color: row.netPnl >= 0 ? overviewSuccessText : overviewDangerText }}>
{formatUsd(row.netPnl)}
</td>
<td>
<div>
<div style={{ color: stateColor, fontWeight: 700 }}>
{row.runtimeState}
</div>
<div style={{ color: overviewMutedText, fontSize: '0.68rem' }}>
{row.runtimeDetail}
</div>
</div>
</td>
</tr>
);
})
) : (
<tr>
<td style={{ fontWeight: 700 }}>Account (Fallback)</td>
<td style={{ textAlign: 'right' }}>{formatUsd(allocatedCapital)}</td>
<td style={{ textAlign: 'right', color: overviewInfoText }}>{formatUsd(capitalUsed)}</td>
<td style={{ textAlign: 'right', color: overAllocatedCapital > 1e-8 ? overviewDangerText : overviewSuccessText }}>
{formatUsd(remainingCapital)}
{overAllocatedCapital > 1e-8 && (
<div style={{ color: overviewAttentionText, fontSize: '0.65rem' }}>
raw +{formatUsd(overAllocatedCapital)}
</div>
)}
</td>
<td style={{ textAlign: 'right' }}>
{allocatedCapital > 0 ? ((capitalUsed / allocatedCapital) * 100).toFixed(1) : '0.0'}%
</td>
<td style={{ textAlign: 'right' }}>-</td>
<td style={{ textAlign: 'right', color: displayRealizedPnl >= 0 ? overviewSuccessText : overviewDangerText }}>{formatUsd(displayRealizedPnl)}</td>
<td>
<span style={{ color: overviewQuietText, fontWeight: 700 }}>No signal</span>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* NEW: Recent Order Rejections List */}
{botState.orderFailures && botState.orderFailures.length > 0 && (
<div style={{ marginBottom: '32px' }}>
<h3 style={{ fontSize: '0.9rem', fontWeight: 700, marginBottom: '12px', color: overviewDangerText }}>Recent Order Rejections</h3>
<div className="table-container">
<table className="pro-table w-full">
<thead>
<tr>
<th>Time</th>
<th>Symbol</th>
<th>Side</th>
<th>Qty</th>
<th>Reason</th>
<th>Sub-tag</th>
<th>Profile</th>
</tr>
</thead>
<tbody>
{botState.orderFailures.slice(0, 10).map((fail, idx) => (
<tr key={idx}>
<td style={{ color: overviewMutedText, fontSize: '0.75rem' }}>{new Date(fail.timestamp).toLocaleString()}</td>
<td style={{ fontWeight: 600 }}>{fail.symbol}</td>
<td className={`side-${fail.side.toLowerCase()}`}>{fail.side}</td>
<td>{fail.qty}</td>
<td style={{ color: overviewDangerText, maxWidth: '300px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={fail.reason}>
{fail.reason}
</td>
<td style={{ color: overviewQuietText, fontFamily: 'monospace' }} title={fail.subTag || ''}>
{compactTag(fail.subTag)}
</td>
<td style={{ color: overviewMutedText }}>{fail.profileId || 'Unknown'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Symbol Cards — role-aware rendering */}
{isAdminView ? (
/* ── ADMIN: Full technical view ─────────────────── */
<div className="readiness-grid">
{symbols.map(symbol => {
const data = botState.symbols[symbol];
const profileSignals = Object.values(data.profileSignals || {});
const bias4h = data.indicators.ema50_4h && data.indicators.ema200_4h
? (data.indicators.ema50_4h > data.indicators.ema200_4h ? 'BULLISH' : 'BEARISH')
: 'NEUTRAL';
const momentum1h = data.indicators.rsi_1h ? (data.indicators.rsi_1h > 50 ? 'BULLISH' : 'BEARISH') : 'NEUTRAL';
const normalizedSignal = String(data.signal || 'NONE').toUpperCase();
const hasDirectionalSignal = normalizedSignal === 'BUY' || normalizedSignal === 'SELL';
const readinessLabel = normalizedSignal === 'MIXED' ? 'PROFILE SIGNALS MIXED' : hasDirectionalSignal ? 'SIGNAL ACTIVE' : 'NO SIGNAL';
const readinessClass = hasDirectionalSignal ? 'yes' : 'maybe';
return (
<div key={symbol} className="readiness-card">
<div className="card-header">
<h3>{symbol}</h3>
<span className={`signal-label ${String(data.signal || 'none').toLowerCase()}`}>{data.signal}</span>
</div>
{profileSignals.length > 0 && (
<div style={{ marginBottom: '0.6rem', display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
{profileSignals.map((ps, idx) => (
<span key={`${symbol}-ps-${idx}`} style={{ fontSize: '0.55rem', fontWeight: 700, letterSpacing: '0.06em', padding: '0.15rem 0.35rem', borderRadius: '8px', border: overviewTagBorder, color: overviewNeutralText }} title={ps.reason || ps.signal}>
{(ps.profileName || `P${idx + 1}`)}: {ps.signal}
</span>
))}
</div>
)}
<div className="metrics-list">
<div className="metric"><span>4H Trend Bias</span><span className={`value ${bias4h.toLowerCase()}`}>{bias4h}</span></div>
<div className="metric"><span>1H Momentum (RSI)</span><span className={`value ${momentum1h.toLowerCase()}`}>{momentum1h} {data.indicators.rsi_1h ? `(${data.indicators.rsi_1h.toFixed(1)})` : ''}</span></div>
<div className="metric"><span>Session</span><span className="value">{data.session}</span></div>
<div className="metric"><span>Volatility</span><span className={`value vol-${data.volatility.toLowerCase()}`}>{data.volatility}</span></div>
{data.indicators.ema50_4h && <div className="metric"><span>EMA50 (4H)</span><span className="value">{data.indicators.ema50_4h.toFixed(2)}</span></div>}
{data.indicators.ema200_4h && <div className="metric"><span>EMA200 (4H)</span><span className="value">{data.indicators.ema200_4h.toFixed(2)}</span></div>}
</div>
<div className="readiness-footer">
<span>Can trade?</span>
<span className={`check ${readinessClass}`}>{readinessLabel}</span>
</div>
</div>
);
})}
</div>
) : (
/* ── CONSUMER: Plain-English friendly cards ────── */
<div className="readiness-grid">
{symbols.map(symbol => {
const data = botState.symbols[symbol];
const normalizedSignal = String(data.signal || 'NONE').toUpperCase();
// ── Friendly translations ──────────────────────────
const bias4h = data.indicators.ema50_4h && data.indicators.ema200_4h
? (data.indicators.ema50_4h > data.indicators.ema200_4h ? 'BULLISH' : 'BEARISH')
: 'NEUTRAL';
const directionLabel = bias4h === 'BULLISH' ? 'Uptrend' : bias4h === 'BEARISH' ? 'Downtrend' : 'Sideways';
const directionColor = bias4h === 'BULLISH' ? overviewSuccessText : bias4h === 'BEARISH' ? overviewDangerText : overviewAttentionText;
const rsi = data.indicators.rsi_1h || 50;
const momentumLabel = rsi > 60 ? 'Building strongly' : rsi > 50 ? 'Gaining' : rsi > 40 ? 'Neutral' : 'Fading';
const momentumColor = rsi > 55 ? overviewSuccessText : rsi > 45 ? overviewAttentionText : overviewDangerText;
const sessionIsOff = data.session === 'OFF' || !data.session;
const sessionLabel = sessionIsOff ? 'Outside window' : 'Active now';
const sessionColor = sessionIsOff ? overviewBotMuted : overviewSuccessText;
const volLabel = data.volatility === 'HIGH' ? 'Very Active' : data.volatility === 'MEDIUM' ? 'Moderate' : data.volatility === 'LOW' ? 'Calm' : 'Normal';
const volColor = data.volatility === 'HIGH' ? overviewAttentionText : data.volatility === 'MEDIUM' ? overviewInfoText : overviewQuietText;
const botStatusEmoji = normalizedSignal === 'BUY' ? '📈' : normalizedSignal === 'SELL' ? '📉' : normalizedSignal === 'MIXED' ? '⚡' : '👀';
const botStatusLabel =
normalizedSignal === 'BUY' ? 'Entry opportunity detected' :
normalizedSignal === 'SELL' ? 'Exit / short signal detected' :
normalizedSignal === 'MIXED' ? 'Conditions mixed — holding off' :
'Watching markets for the right setup';
const botStatusColor =
normalizedSignal === 'BUY' ? overviewSuccessText :
normalizedSignal === 'SELL' ? overviewDangerText :
normalizedSignal === 'MIXED' ? overviewAttentionText :
overviewBotMuted;
const change24h = data.change24h || 0;
return (
<div key={symbol} style={{
background: overviewCardGradient,
border: overviewCardBorder,
borderRadius: '20px',
padding: '24px',
display: 'flex',
flexDirection: 'column',
gap: '0',
transition: 'all 0.2s ease',
position: 'relative',
overflow: 'hidden'
}}>
{/* Top accent line based on signal */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '2px', background: botStatusColor, opacity: normalizedSignal === 'NONE' ? 0.15 : 0.6 }} />
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '20px' }}>
<div>
<div style={{ fontSize: '1.1rem', fontWeight: 900, color: overviewNeutralText, letterSpacing: '-0.02em' }}>
{symbol.split('/')[0]}
<span style={{ fontSize: '0.7rem', color: overviewBotQuiet, fontWeight: 600, marginLeft: '4px' }}>/{symbol.split('/')[1] || 'USDT'}</span>
</div>
<div style={{ fontSize: '0.75rem', fontWeight: 700, color: change24h >= 0 ? overviewSuccessText : overviewDangerText, marginTop: '2px', fontFamily: 'monospace' }}>
{change24h >= 0 ? '+' : ''}{change24h.toFixed(2)}% today
</div>
</div>
<div style={{ fontSize: '1.4rem' }}>{botStatusEmoji}</div>
</div>
{/* Friendly metrics */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', flex: 1 }}>
{[
{ label: 'Market Direction', value: directionLabel, color: directionColor },
{ label: 'Short-term Momentum', value: momentumLabel, color: momentumColor },
{ label: 'Trading Window', value: sessionLabel, color: sessionColor },
{ label: 'Market Activity', value: volLabel, color: volColor },
].map(({ label, value, color }) => (
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '0.78rem', color: overviewBotMuted, fontWeight: 600 }}>{label}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{ width: '6px', height: '6px', borderRadius: '50%', background: color, boxShadow: `0 0 6px ${color}` }} />
<span style={{ fontSize: '0.78rem', fontWeight: 800, color: overviewNeutralText }}>{value}</span>
</div>
</div>
))}
</div>
{/* Bot Status Footer */}
<div style={{ marginTop: '20px', paddingTop: '16px', borderTop: overviewDivider }}>
<div style={{ fontSize: '0.68rem', color: overviewBotQuiet, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '4px' }}>Bot Status</div>
<div style={{ fontSize: '0.82rem', fontWeight: 800, color: botStatusColor === overviewBotMuted ? overviewQuietText : botStatusColor }}>
{botStatusLabel}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
};