1062 lines
56 KiB
TypeScript
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>
|
|
);
|
|
};
|