diff --git a/web/src/main.tsx b/web/src/main.tsx index 94c2ea5..6448e4b 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -4,6 +4,7 @@ import { Agentation } from 'agentation' import './index.css' import './App.css' import './layout-fixes.css' +import './styles/landing-views.css' import App from './App.tsx' import { AuthProvider } from './components/AuthContext'; import { ProductAccessibilityGate } from './components/ProductAccessibilityGate'; diff --git a/web/src/styles/landing-views.css b/web/src/styles/landing-views.css new file mode 100644 index 0000000..ffcb605 --- /dev/null +++ b/web/src/styles/landing-views.css @@ -0,0 +1,91 @@ +/* --------------------------------------------------------------------------- + landing-views.css — extracted patterns from OverviewTab, HomeView, MyStrategiesTab + (UI audit #8, Pattern B). Keeps token-driven structural styles in CSS so they + can have @media queries and so consumers can override via class composition. + One-off styles remain inline (already var(--bl-*) driven). +--------------------------------------------------------------------------- */ + +/* Card / panel container — used across overview metric cards, strategy + profile cards, and home preview panels. */ +.lv-card { + background: var(--card); + border-radius: 12px; + border: 1px solid var(--border); + padding: 16px; +} + +/* Larger structural card — overview "tile" / strategy profile shell. */ +.lv-card-lg { + background: var(--bl-surface-overlay); + border: 1px solid var(--bl-border-subtle); + padding: 16px; + border-radius: 20px; + display: flex; + flex-direction: column; +} + +/* Highlight chip / icon tag — small square holder for an icon + accent. */ +.lv-icon-tag { + width: 38px; + height: 38px; + border-radius: 10px; + background: var(--bl-surface-highlight); + border: 1px solid var(--bl-border-subtle); + display: flex; + align-items: center; + justify-content: center; +} + +/* Soft 24-radius surface — used by strategy profile sections, education panels. */ +.lv-surface { + background: var(--bl-surface-highlight); + border: 1px solid var(--bl-border-subtle); + border-radius: 24px; + padding: 16px; +} + +/* Eyebrow / section label — small, bold, uppercase, letter-spaced. */ +.lv-eyebrow { + font-size: 11px; + font-weight: 900; + color: var(--bl-text-quiet); + text-transform: uppercase; + letter-spacing: 2px; +} + +/* Section title — bold token-foreground at fixed metric size. */ +.lv-section-title { + font-size: 13px; + font-weight: 700; + color: var(--foreground); + margin-bottom: 10px; +} + +/* Section subtitle — muted, fixed metric size. */ +.lv-section-sub { + font-size: 12px; + color: var(--muted-foreground); +} + +/* Empty-state text — italic, centered, padded, tertiary color. */ +.lv-empty-text { + text-align: center; + padding: 16px; + color: var(--bl-text-tertiary); + font-size: 12px; + font-style: italic; +} + +/* Top-bordered divider with breathing room — used for footer-like rows + inside cards (totals, last-updated stamps). */ +.lv-divider-row { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--border); +} + +/* Tiny meta value — used for fine-print metrics next to the eyebrow. */ +.lv-meta-faint { + font-size: 0.65rem; + color: var(--bl-text-faint); +} diff --git a/web/src/tabs/MyStrategiesTab.tsx b/web/src/tabs/MyStrategiesTab.tsx index f604126..e1f6502 100644 --- a/web/src/tabs/MyStrategiesTab.tsx +++ b/web/src/tabs/MyStrategiesTab.tsx @@ -3,24 +3,24 @@ import { useAuth } from '../components/AuthContext'; import { getStrategyExplanation } from '../lib/StrategyExplanationService'; import { getUserTier } from '../lib/TierPolicy'; import { - Play, - Pause, - Trash2, - Activity, - TrendingUp, - Plus, - Shield, - Zap, - Scale, - Settings, - ChevronDown, - ChevronUp, - Lightbulb, - Cpu, - Fingerprint, - Target, - DollarSign, - Lock + Play, + Pause, + Trash2, + Activity, + TrendingUp, + Plus, + Shield, + Zap, + Scale, + Settings, + ChevronDown, + ChevronUp, + Lightbulb, + Cpu, + Fingerprint, + Target, + DollarSign, + Lock } from 'lucide-react'; import { StrategyWizard } from '../components/StrategyWizard'; import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel'; @@ -30,459 +30,459 @@ import { Button, IconButton } from '../components/ui/Primitives'; import { CardButton } from '@bytelyst/ui'; function getStrategyKindLabel(config: any) { - if (config?.type === 'visual') return 'Visual Builder'; - if (config?.type === 'code') return 'Code Strategy'; - return 'V4.0 Core'; + if (config?.type === 'visual') return 'Visual Builder'; + if (config?.type === 'code') return 'Code Strategy'; + return 'V4.0 Core'; } const ActiveStrategyCard: React.FC<{ - profile: any; - botState: any; - tier: string; - onToggle: (p: any) => void; - onEdit: (p: any) => void; - onBacktest?: (p: any) => void; - onDelete: (id: string) => void; - isExpanded: boolean; - onToggleExpand: (id: string) => void; + profile: any; + botState: any; + tier: string; + onToggle: (p: any) => void; + onEdit: (p: any) => void; + onBacktest?: (p: any) => void; + onDelete: (id: string) => void; + isExpanded: boolean; + onToggleExpand: (id: string) => void; }> = ({ profile, botState, tier, onToggle, onEdit, onBacktest, onDelete, isExpanded, onToggleExpand }) => { - const config = profile.strategy_config; - const isAggressive = config?.execution?.minRulePassRatio < 0.9; - const isSafe = config?.execution?.minRulePassRatio >= 1.0; - const strategyKindLabel = getStrategyKindLabel(config); + const config = profile.strategy_config; + const isAggressive = config?.execution?.minRulePassRatio < 0.9; + const isSafe = config?.execution?.minRulePassRatio >= 1.0; + const strategyKindLabel = getStrategyKindLabel(config); - const explanation = getStrategyExplanation(profile, botState); + const explanation = getStrategyExplanation(profile, botState); - return ( -
+ return ( +
- {/* 1. Direct Status Strip */} -
+ {/* 1. Direct Status Strip */} +
- {/* 2. Header Area */} -
-
-
- {isSafe ? : isAggressive ? : } -
-
- Active Strategy - - {profile.is_active ? 'Running' : 'Paused'} - -
-
-
- {onBacktest && ( - } onClick={() => onBacktest(profile)} style={{ width: '38px', height: '38px', borderRadius: '10px', background: 'var(--bl-surface-highlight)', border: '1px solid var(--bl-border-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--bl-text-quiet)', cursor: 'pointer' }} className="icon-btn-hover" /> - )} - } onClick={() => onEdit(profile)} style={{ width: '38px', height: '38px', borderRadius: '10px', background: 'var(--bl-surface-highlight)', border: '1px solid var(--bl-border-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--bl-text-quiet)', cursor: 'pointer' }} className="icon-btn-hover" /> - } onClick={() => onDelete(profile.id)} style={{ width: '38px', height: '38px', borderRadius: '10px', background: 'var(--bl-surface-highlight)', border: '1px solid var(--bl-border-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--bl-text-quiet)', cursor: 'pointer' }} className="icon-btn-hover" /> -
-
+ {/* 2. Header Area */} +
+
+
+ {isSafe ? : isAggressive ? : } +
+
+ Active Strategy + + {profile.is_active ? 'Running' : 'Paused'} + +
+
+
+ {onBacktest && ( + } onClick={() => onBacktest(profile)} style={{ width: '38px', height: '38px', borderRadius: '10px', background: 'var(--bl-surface-highlight)', border: '1px solid var(--bl-border-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--bl-text-quiet)', cursor: 'pointer' }} className="icon-btn-hover" /> + )} + } onClick={() => onEdit(profile)} style={{ width: '38px', height: '38px', borderRadius: '10px', background: 'var(--bl-surface-highlight)', border: '1px solid var(--bl-border-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--bl-text-quiet)', cursor: 'pointer' }} className="icon-btn-hover" /> + } onClick={() => onDelete(profile.id)} style={{ width: '38px', height: '38px', borderRadius: '10px', background: 'var(--bl-surface-highlight)', border: '1px solid var(--bl-border-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--bl-text-quiet)', cursor: 'pointer' }} className="icon-btn-hover" /> +
+
- {/* 3. Identity */} -
-

{profile.name}

-
- - {profile.symbols} - - - {strategyKindLabel} - -
-
+ {/* 3. Identity */} +
+

{profile.name}

+
+ + {profile.symbols} + + + {strategyKindLabel} + +
+
- {/* 4. Operational DNA (Specs) */} -
- {[ - { label: 'Allocation', value: `$${profile.allocated_capital.toLocaleString()}`, icon: , color: 'var(--bl-success)' }, - { label: 'PnL (Global)', value: '$0.00', icon: , color: 'var(--bl-info-strong)' }, - { label: 'Target', value: `$${config?.riskLimits?.dailyProfitTargetUsd || 0}`, icon: , color: 'var(--bl-warning)' }, - { label: 'Latency', value: '5ms', icon: , color: 'var(--bl-danger)' } - ].map((spec, i) => ( -
-
- {spec.icon} {spec.label} -
-
{spec.value}
-
- ))} -
+ {/* 4. Operational DNA (Specs) */} +
+ {[ + { label: 'Allocation', value: `$${profile.allocated_capital.toLocaleString()}`, icon: , color: 'var(--bl-success)' }, + { label: 'PnL (Global)', value: '$0.00', icon: , color: 'var(--bl-info-strong)' }, + { label: 'Target', value: `$${config?.riskLimits?.dailyProfitTargetUsd || 0}`, icon: , color: 'var(--bl-warning)' }, + { label: 'Latency', value: '5ms', icon: , color: 'var(--bl-danger)' } + ].map((spec, i) => ( +
+
+ {spec.icon} {spec.label} +
+
{spec.value}
+
+ ))} +
- {/* 5. Health Diagnostic (Education Layer) */} -
- onToggleExpand(profile.id)} - style={{ - width: '100%', - padding: '16px', - borderRadius: '20px', - background: 'var(--bl-surface-highlight)', - border: '1px solid var(--bl-border-subtle)', - display: 'flex', - flexDirection: 'column', - gap: '8px', - cursor: 'pointer', - textAlign: 'left' - }} - > -
-
- Diagnostic Intelligence {tier === 'free' && } -
- {isExpanded ? : } -
-

- {explanation.reason} -

- {isExpanded && explanation.recommendation && ( -
-
- Optimization -
-

{explanation.recommendation}

-
- )} -
-
+ {/* 5. Health Diagnostic (Education Layer) */} +
+ onToggleExpand(profile.id)} + style={{ + width: '100%', + padding: '16px', + borderRadius: '20px', + background: 'var(--bl-surface-highlight)', + border: '1px solid var(--bl-border-subtle)', + display: 'flex', + flexDirection: 'column', + gap: '8px', + cursor: 'pointer', + textAlign: 'left' + }} + > +
+
+ Diagnostic Intelligence {tier === 'free' && } +
+ {isExpanded ? : } +
+

+ {explanation.reason} +

+ {isExpanded && explanation.recommendation && ( +
+
+ Optimization +
+

{explanation.recommendation}

+
+ )} +
+
- {/* 6. Action */} -
- -
+ {/* 6. Action */} +
+ +
- -
- ); + +
+ ); }; export const MyStrategiesTab: React.FC<{ botState: any; alerts?: any[]; previewAsCustomer?: boolean }> = ({ - botState, - alerts = [], - previewAsCustomer = false + botState, + alerts = [], + previewAsCustomer = false }) => { - const { user, profile: userProfile } = useAuth(); - const [profiles, setProfiles] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [showWizard, setShowWizard] = useState(false); - const [editingProfile, setEditingProfile] = useState(null); - const [expandedExplanations, setExpandedExplanations] = useState>({}); - const [backtestProfile, setBacktestProfile] = useState(null); + const { user, profile: userProfile } = useAuth(); + const [profiles, setProfiles] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showWizard, setShowWizard] = useState(false); + const [editingProfile, setEditingProfile] = useState(null); + const [expandedExplanations, setExpandedExplanations] = useState>({}); + const [backtestProfile, setBacktestProfile] = useState(null); - const tier = getUserTier(userProfile); - const { enabled: backtestEnabled, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer }); + const tier = getUserTier(userProfile); + const { enabled: backtestEnabled, loading: backtestGateLoading } = useBacktestFeatureGate({ previewAsCustomer }); - const fetchProfiles = async () => { - if (!user) return; - setIsLoading(true); - const data = await fetchTradeProfiles(); - setProfiles(data || []); - setIsLoading(false); - }; + const fetchProfiles = async () => { + if (!user) return; + setIsLoading(true); + const data = await fetchTradeProfiles(); + setProfiles(data || []); + setIsLoading(false); + }; - useEffect(() => { - fetchProfiles(); - window.addEventListener('profiles-updated', fetchProfiles); - return () => window.removeEventListener('profiles-updated', fetchProfiles); - }, [user]); + useEffect(() => { + fetchProfiles(); + window.addEventListener('profiles-updated', fetchProfiles); + return () => window.removeEventListener('profiles-updated', fetchProfiles); + }, [user]); - const toggleBot = async (profile: any) => { - try { - await setTradeProfileActive(profile.id, !profile.is_active); - fetchProfiles(); - } catch { - // existing UI remains silent on toggle failure - } - }; + const toggleBot = async (profile: any) => { + try { + await setTradeProfileActive(profile.id, !profile.is_active); + fetchProfiles(); + } catch { + // existing UI remains silent on toggle failure + } + }; - const deleteBot = async (id: string) => { - if (!confirm('Are you sure you want to delete this strategy?')) return; - try { - await deleteTradeProfile(id); - fetchProfiles(); - } catch { - // existing UI remains silent on delete failure - } - }; + const deleteBot = async (id: string) => { + if (!confirm('Are you sure you want to delete this strategy?')) return; + try { + await deleteTradeProfile(id); + fetchProfiles(); + } catch { + // existing UI remains silent on delete failure + } + }; - if (showWizard) { - return ( -
-
- -
- { - setShowWizard(false); - setEditingProfile(null); - fetchProfiles(); - }} - /> -
- ); - } + if (showWizard) { + return ( +
+
+ +
+ { + setShowWizard(false); + setEditingProfile(null); + fetchProfiles(); + }} + /> +
+ ); + } - return ( -
-
-
-
- Strategy operations -
-

My strategies

-

- Monitor active profiles, review recent signals, and create new automated trading workflows. -

-
+ return ( +
+
+
+
+ Strategy operations +
+

My strategies

+

+ Monitor active profiles, review recent signals, and create new automated trading workflows. +

+
-
-
- {botState?.connected ? 'Systems online' : 'Systems disconnected'} -
- -
-
+
+
+ {botState?.connected ? 'Systems online' : 'Systems disconnected'} +
+ +
+
- {/* Contextual Intelligence Row: Recent Activity + Symbol Volatility */} - {(() => { - const activeSymbols = [...new Set(profiles.flatMap(p => p.symbols?.split(',').map((s: string) => s.trim()) || []))]; - const recentAlerts = [...alerts].reverse().slice(0, 5); - const symbolVolatility = activeSymbols - .filter(s => botState?.symbols?.[s]) - .map(s => ({ symbol: s, change: botState.symbols[s].change24h || 0 })) - .sort((a, b) => Math.abs(b.change) - Math.abs(a.change)); + {/* Contextual Intelligence Row: Recent Activity + Symbol Volatility */} + {(() => { + const activeSymbols = [...new Set(profiles.flatMap(p => p.symbols?.split(',').map((s: string) => s.trim()) || []))]; + const recentAlerts = [...alerts].reverse().slice(0, 5); + const symbolVolatility = activeSymbols + .filter(s => botState?.symbols?.[s]) + .map(s => ({ symbol: s, change: botState.symbols[s].change24h || 0 })) + .sort((a, b) => Math.abs(b.change) - Math.abs(a.change)); - return ( -
- {/* Recent Activity */} -
-
-
- Recent Activity -
-
- {recentAlerts.map((alert, i) => { - const mins = Math.floor((Date.now() - alert.timestamp) / 60000); - const timeAgo = mins < 1 ? 'just now' : mins < 60 ? `${mins}m ago` : `${Math.floor(mins / 60)}h ago`; - return ( -
- - {alert.symbol} - {alert.message} - {timeAgo} -
- ); - })} - {recentAlerts.length === 0 &&
No activity yet...
} -
-
+ return ( +
+ {/* Recent Activity */} +
+
+
+ Recent Activity +
+
+ {recentAlerts.map((alert, i) => { + const mins = Math.floor((Date.now() - alert.timestamp) / 60000); + const timeAgo = mins < 1 ? 'just now' : mins < 60 ? `${mins}m ago` : `${Math.floor(mins / 60)}h ago`; + return ( +
+ + {alert.symbol} + {alert.message} + {timeAgo} +
+ ); + })} + {recentAlerts.length === 0 &&
No activity yet...
} +
+
- {/* Symbol-Specific Volatility */} -
-
- - Your Markets (24h) -
-
- {symbolVolatility.map(({ symbol, change }) => ( -
- {symbol} -
-
-
= 0 ? 'var(--bl-success)' : 'var(--bl-danger)', borderRadius: '99px' }} /> -
- = 0 ? 'var(--bl-success)' : 'var(--bl-danger)', minWidth: '55px', textAlign: 'right' }}> - {change >= 0 ? '+' : ''}{change.toFixed(2)}% - -
-
- ))} - {symbolVolatility.length === 0 &&
Deploy a strategy to see its market data
} -
-
-
- ); - })()} -
- {!backtestGateLoading && backtestEnabled && backtestProfile && ( -
- s.trim()).filter(Boolean)} - initialCapitalUsd={Number(backtestProfile.allocated_capital || 1000)} - title={`Backtest: ${backtestProfile.name}`} - onClose={() => setBacktestProfile(null)} - /> -
- )} - {profiles.map(profile => ( - { - setEditingProfile(p); - setShowWizard(true); - }} - onBacktest={!backtestGateLoading && backtestEnabled ? ((p) => setBacktestProfile(p)) : undefined} - onDelete={deleteBot} - isExpanded={!!expandedExplanations[profile.id]} - onToggleExpand={(id) => setExpandedExplanations(prev => ({ ...prev, [id]: !prev[id] }))} - /> - ))} + {/* Symbol-Specific Volatility */} +
+
+ + Your Markets (24h) +
+
+ {symbolVolatility.map(({ symbol, change }) => ( +
+ {symbol} +
+
+
= 0 ? 'var(--bl-success)' : 'var(--bl-danger)', borderRadius: '99px' }} /> +
+ = 0 ? 'var(--bl-success)' : 'var(--bl-danger)', minWidth: '55px', textAlign: 'right' }}> + {change >= 0 ? '+' : ''}{change.toFixed(2)}% + +
+
+ ))} + {symbolVolatility.length === 0 &&
Deploy a strategy to see its market data
} +
+
+
+ ); + })()} +
+ {!backtestGateLoading && backtestEnabled && backtestProfile && ( +
+ s.trim()).filter(Boolean)} + initialCapitalUsd={Number(backtestProfile.allocated_capital || 1000)} + title={`Backtest: ${backtestProfile.name}`} + onClose={() => setBacktestProfile(null)} + /> +
+ )} + {profiles.map(profile => ( + { + setEditingProfile(p); + setShowWizard(true); + }} + onBacktest={!backtestGateLoading && backtestEnabled ? ((p) => setBacktestProfile(p)) : undefined} + onDelete={deleteBot} + isExpanded={!!expandedExplanations[profile.id]} + onToggleExpand={(id) => setExpandedExplanations(prev => ({ ...prev, [id]: !prev[id] }))} + /> + ))} - {profiles.length === 0 && !isLoading && ( -
- -

No strategies yet

-

Create your first strategy to start monitoring markets and testing automated execution.

- -
- )} -
+ {profiles.length === 0 && !isLoading && ( +
+ +

No strategies yet

+

Create your first strategy to start monitoring markets and testing automated execution.

+ +
+ )} +
- -
- ); + +
+ ); }; diff --git a/web/src/tabs/OverviewTab.tsx b/web/src/tabs/OverviewTab.tsx index a800f68..6348059 100644 --- a/web/src/tabs/OverviewTab.tsx +++ b/web/src/tabs/OverviewTab.tsx @@ -7,41 +7,41 @@ import { fetchTradeProfiles } from '../lib/profileApi'; import { Button } from '../components/ui/Primitives'; interface OverviewTabProps { - botState: BotState; - previewAsCustomer?: boolean; - connected?: boolean; + botState: BotState; + previewAsCustomer?: boolean; + connected?: boolean; } interface ActiveProfileCapital { - id: string; - name: string; - allocated: number; - cooldownMinutes: number; + id: string; + name: string; + allocated: number; + cooldownMinutes: number; } interface TradeAggregate { - tradeCount: number; - wins: number; - winRate: number; - netPnl: number; - lastClosedTradeAt: number; + tradeCount: number; + wins: number; + winRate: number; + netPnl: number; + lastClosedTradeAt: number; } interface ProfileSignalAggregate { - totalSignals: number; - activeSignals: number; - blockedSignals: number; - skippedSignals: number; - executedSignals: number; + 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' } + { 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)'; @@ -70,992 +70,992 @@ const overviewTagBorder = '1px solid color-mix(in oklab, var(--bl-border) 82%, t 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`; + 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`; + 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 })}`; + `$${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; + 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(); + const mergedByKey = new Map(); - 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); - }; + 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}`; + 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 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 - }); - } + 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()); + 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([]); - const [winRateWindow, setWinRateWindow] = useState('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(); - const unrealizedByProfile = new Map(); - const openTradesByProfile = new Map(); - 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(), - unrealizedByProfile: new Map(), - openTradesByProfile: new Map() - }; - }, [canonicalLifecycleReady, canonicalSnapshot]); - - const liveOpenByProfile = useMemo(() => { - const usedByProfile = new Map(); - const unrealizedByProfile = new Map(); - const openTradesByProfile = new Map(); - 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(lifecycleOpenByProfile.usedByProfile); - const unrealizedByProfile = new Map(lifecycleOpenByProfile.unrealizedByProfile); - const openTradesByProfile = new Map(lifecycleOpenByProfile.openTradesByProfile); - const suppressedLifecycleNotionalByProfile = new Map(); - - // 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(); - 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 }; - - 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 = {}; - 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 = {}; - 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 ( -
-
-

Market Readiness

-

Global view of market conditions and bot status.

-
- - {canonicalUnavailable && ( -
- Canonical lifecycle is unavailable{canonicalLoading ? ' (loading)' : ''}. Showing fallback values from DB/runtime sources. - {canonicalError ? ` ${canonicalError}` : ''} -
- )} - {canonicalSnapshot?.diagnostics?.truncated && ( -
- Canonical lifecycle snapshot is truncated ({canonicalSnapshot.diagnostics.orderRows} rows). Narrow scope before using this for operational decisions. -
- )} - -
- - Win Rate Window: - - {WIN_RATE_WINDOW_OPTIONS.map((option) => { - const active = option.key === winRateWindow; - return ( - - ); - })} -
- -
-
- System: - {(() => { - const mode = botState?.health?.tradingControl?.mode; - const wsConnected = connected; - - if (!wsConnected) return DISCONNECTED; - if (mode === 'PAUSED') return PAUSED; - if (mode === 'RUNNING' || !mode) return RUNNING; - return {mode}; - })()} -
-
- Mode: - - {botState.settings.executionMode} - -
-
- Allocated: - {formatUsd(allocatedCapital)} - {profileCount > 0 && ({profileCount} profiles)} -
-
- Capital Used: - {formatUsd(capitalUsed)} - {overAllocatedCapital > 1e-8 && ( - - capped (+{formatUsd(overAllocatedCapital)} raw) - - )} -
-
- Remaining: - 1e-8 ? 'status-offline' : 'status-online'}`}> - {formatUsd(remainingCapital)} - -
-
- Uptime: - {formatUptime(botState.uptime)} -
-
- Realized P&L (90d): - = 0 ? 'status-online' : 'status-offline'}`}> - {formatUsd(displayRealizedPnl)} - -
-
- Net P&L (90d): - = 0 ? 'status-online' : 'status-offline'}`}> - {formatUsd(netPnl)} - -
-
- Win Rate ({winRateWindowConfig.label}): - = 50 ? overviewSuccessText : overviewWarningText }}> - {displayWindowAggregate.winRate.toFixed(1)}% - - - {displayWindowAggregate.tradeCount} trades - -
-
- P&L Duration: - 0 - ? `${new Date(pnlWindow.fromTs).toLocaleString()} -> ${new Date(pnlWindow.toTs).toLocaleString()}` - : 'No realized trades yet'} - > - {pnlWindow.durationLabel} - - - {pnlWindow.fromTs > 0 - ? `From ${new Date(pnlWindow.fromTs).toLocaleDateString()}` - : 'Awaiting trade history'} - -
-
- - {/* --- NEW: Alpaca Account Health --- */} -
-
- Broker Balance (Alpaca): - - {botState.accountSnapshot - ? formatUsd(botState.accountSnapshot.buying_power) - : 'Waiting for snapshot...'} - - {botState.accountSnapshot && ( - - Cash: {formatUsd(botState.accountSnapshot.cash)} ({botState.accountSnapshot.currency}) - - )} -
- {botState.accountSnapshot && ( -
- Last Sync: - - {new Date(botState.accountSnapshot.timestamp).toLocaleTimeString()} - -
- )} - {/* Parity & Self-Healing Heartbeat */} -
- Parity Heartbeat: - - {botState.health?.reconciliationParityMismatchTrades || 0} Mismatches - - {Number(botState.health?.reconciliationParityAutoClosedTrades || 0) > 0 && ( - - ({botState.health?.reconciliationParityAutoClosedTrades} Self-Healed) - - )} - {Number(botState.health?.reconciliationParityQuarantinedTrades || 0) > 0 && ( - - ({botState.health?.reconciliationParityQuarantinedTrades} Quarantined) - - )} -
- {/* Recent Failures Summary */} - {(botState.orderFailures || []).length > 0 && ( -
- Recent Rejections: - {(botState.orderFailures || []).length} - - Latest: {(botState.orderFailures || [])[0].symbol} ({(botState.orderFailures || [])[0].reason?.substring(0, 20)}...) - -
- )} -
- -
- - - - - - - - - - - - - - - {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 ( - - - - - - - - - - - ); - }) - ) : ( - - - - - - - - - - - )} - -
Capital ScopeAllocatedUsedRemainingUtilizationWin Rate ({winRateWindowConfig.label})Realized P&LState
{row.name}{formatUsd(row.allocated)}{formatUsd(row.used)} - {formatUsd(row.remaining)} - {overAllocated && ( -
- raw +{formatUsd(row.overAllocatedAmount)} -
- )} -
- {row.utilizationPct.toFixed(1)}% - = 50 ? overviewSuccessText : overviewWarningText }}> - {row.winRate.toFixed(1)}% - - ({row.tradeCount}) - - = 0 ? overviewSuccessText : overviewDangerText }}> - {formatUsd(row.netPnl)} - -
-
- {row.runtimeState} -
-
- {row.runtimeDetail} -
-
-
Account (Fallback){formatUsd(allocatedCapital)}{formatUsd(capitalUsed)} 1e-8 ? overviewDangerText : overviewSuccessText }}> - {formatUsd(remainingCapital)} - {overAllocatedCapital > 1e-8 && ( -
- raw +{formatUsd(overAllocatedCapital)} -
- )} -
- {allocatedCapital > 0 ? ((capitalUsed / allocatedCapital) * 100).toFixed(1) : '0.0'}% - -= 0 ? overviewSuccessText : overviewDangerText }}>{formatUsd(displayRealizedPnl)} - No signal -
-
- - {/* NEW: Recent Order Rejections List */} - {botState.orderFailures && botState.orderFailures.length > 0 && ( -
-

Recent Order Rejections

-
- - - - - - - - - - - - - - {botState.orderFailures.slice(0, 10).map((fail, idx) => ( - - - - - - - - - - ))} - -
TimeSymbolSideQtyReasonSub-tagProfile
{new Date(fail.timestamp).toLocaleString()}{fail.symbol}{fail.side}{fail.qty} - {fail.reason} - - {compactTag(fail.subTag)} - {fail.profileId || 'Unknown'}
-
-
- )} - - {/* Symbol Cards — role-aware rendering */} - {isAdminView ? ( - /* ── ADMIN: Full technical view ─────────────────── */ -
- {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 ( -
-
-

{symbol}

- {data.signal} -
- {profileSignals.length > 0 && ( -
- {profileSignals.map((ps, idx) => ( - - {(ps.profileName || `P${idx + 1}`)}: {ps.signal} - - ))} -
- )} -
-
4H Trend Bias{bias4h}
-
1H Momentum (RSI){momentum1h} {data.indicators.rsi_1h ? `(${data.indicators.rsi_1h.toFixed(1)})` : ''}
-
Session{data.session}
-
Volatility{data.volatility}
- {data.indicators.ema50_4h &&
EMA50 (4H){data.indicators.ema50_4h.toFixed(2)}
} - {data.indicators.ema200_4h &&
EMA200 (4H){data.indicators.ema200_4h.toFixed(2)}
} -
-
- Can trade? - {readinessLabel} -
-
- ); - })} -
- ) : ( - /* ── CONSUMER: Plain-English friendly cards ────── */ -
- {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 ( -
- {/* Top accent line based on signal */} -
- - {/* Header */} -
-
-
- {symbol.split('/')[0]} - /{symbol.split('/')[1] || 'USDT'} -
-
= 0 ? overviewSuccessText : overviewDangerText, marginTop: '2px', fontFamily: 'monospace' }}> - {change24h >= 0 ? '+' : ''}{change24h.toFixed(2)}% today -
-
-
{botStatusEmoji}
-
- - {/* Friendly metrics */} -
- {[ - { 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 }) => ( -
- {label} -
-
- {value} -
-
- ))} -
- - {/* Bot Status Footer */} -
-
Bot Status
-
- {botStatusLabel} -
-
-
- ); - })} -
- )} -
- ); + 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([]); + const [winRateWindow, setWinRateWindow] = useState('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(); + const unrealizedByProfile = new Map(); + const openTradesByProfile = new Map(); + 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(), + unrealizedByProfile: new Map(), + openTradesByProfile: new Map() + }; + }, [canonicalLifecycleReady, canonicalSnapshot]); + + const liveOpenByProfile = useMemo(() => { + const usedByProfile = new Map(); + const unrealizedByProfile = new Map(); + const openTradesByProfile = new Map(); + 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(lifecycleOpenByProfile.usedByProfile); + const unrealizedByProfile = new Map(lifecycleOpenByProfile.unrealizedByProfile); + const openTradesByProfile = new Map(lifecycleOpenByProfile.openTradesByProfile); + const suppressedLifecycleNotionalByProfile = new Map(); + + // 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(); + 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 }; + + 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 = {}; + 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 = {}; + 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 ( +
+
+

Market Readiness

+

Global view of market conditions and bot status.

+
+ + {canonicalUnavailable && ( +
+ Canonical lifecycle is unavailable{canonicalLoading ? ' (loading)' : ''}. Showing fallback values from DB/runtime sources. + {canonicalError ? ` ${canonicalError}` : ''} +
+ )} + {canonicalSnapshot?.diagnostics?.truncated && ( +
+ Canonical lifecycle snapshot is truncated ({canonicalSnapshot.diagnostics.orderRows} rows). Narrow scope before using this for operational decisions. +
+ )} + +
+ + Win Rate Window: + + {WIN_RATE_WINDOW_OPTIONS.map((option) => { + const active = option.key === winRateWindow; + return ( + + ); + })} +
+ +
+
+ System: + {(() => { + const mode = botState?.health?.tradingControl?.mode; + const wsConnected = connected; + + if (!wsConnected) return DISCONNECTED; + if (mode === 'PAUSED') return PAUSED; + if (mode === 'RUNNING' || !mode) return RUNNING; + return {mode}; + })()} +
+
+ Mode: + + {botState.settings.executionMode} + +
+
+ Allocated: + {formatUsd(allocatedCapital)} + {profileCount > 0 && ({profileCount} profiles)} +
+
+ Capital Used: + {formatUsd(capitalUsed)} + {overAllocatedCapital > 1e-8 && ( + + capped (+{formatUsd(overAllocatedCapital)} raw) + + )} +
+
+ Remaining: + 1e-8 ? 'status-offline' : 'status-online'}`}> + {formatUsd(remainingCapital)} + +
+
+ Uptime: + {formatUptime(botState.uptime)} +
+
+ Realized P&L (90d): + = 0 ? 'status-online' : 'status-offline'}`}> + {formatUsd(displayRealizedPnl)} + +
+
+ Net P&L (90d): + = 0 ? 'status-online' : 'status-offline'}`}> + {formatUsd(netPnl)} + +
+
+ Win Rate ({winRateWindowConfig.label}): + = 50 ? overviewSuccessText : overviewWarningText }}> + {displayWindowAggregate.winRate.toFixed(1)}% + + + {displayWindowAggregate.tradeCount} trades + +
+
+ P&L Duration: + 0 + ? `${new Date(pnlWindow.fromTs).toLocaleString()} -> ${new Date(pnlWindow.toTs).toLocaleString()}` + : 'No realized trades yet'} + > + {pnlWindow.durationLabel} + + + {pnlWindow.fromTs > 0 + ? `From ${new Date(pnlWindow.fromTs).toLocaleDateString()}` + : 'Awaiting trade history'} + +
+
+ + {/* --- NEW: Alpaca Account Health --- */} +
+
+ Broker Balance (Alpaca): + + {botState.accountSnapshot + ? formatUsd(botState.accountSnapshot.buying_power) + : 'Waiting for snapshot...'} + + {botState.accountSnapshot && ( + + Cash: {formatUsd(botState.accountSnapshot.cash)} ({botState.accountSnapshot.currency}) + + )} +
+ {botState.accountSnapshot && ( +
+ Last Sync: + + {new Date(botState.accountSnapshot.timestamp).toLocaleTimeString()} + +
+ )} + {/* Parity & Self-Healing Heartbeat */} +
+ Parity Heartbeat: + + {botState.health?.reconciliationParityMismatchTrades || 0} Mismatches + + {Number(botState.health?.reconciliationParityAutoClosedTrades || 0) > 0 && ( + + ({botState.health?.reconciliationParityAutoClosedTrades} Self-Healed) + + )} + {Number(botState.health?.reconciliationParityQuarantinedTrades || 0) > 0 && ( + + ({botState.health?.reconciliationParityQuarantinedTrades} Quarantined) + + )} +
+ {/* Recent Failures Summary */} + {(botState.orderFailures || []).length > 0 && ( +
+ Recent Rejections: + {(botState.orderFailures || []).length} + + Latest: {(botState.orderFailures || [])[0].symbol} ({(botState.orderFailures || [])[0].reason?.substring(0, 20)}...) + +
+ )} +
+ +
+ + + + + + + + + + + + + + + {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 ( + + + + + + + + + + + ); + }) + ) : ( + + + + + + + + + + + )} + +
Capital ScopeAllocatedUsedRemainingUtilizationWin Rate ({winRateWindowConfig.label})Realized P&LState
{row.name}{formatUsd(row.allocated)}{formatUsd(row.used)} + {formatUsd(row.remaining)} + {overAllocated && ( +
+ raw +{formatUsd(row.overAllocatedAmount)} +
+ )} +
+ {row.utilizationPct.toFixed(1)}% + = 50 ? overviewSuccessText : overviewWarningText }}> + {row.winRate.toFixed(1)}% + + ({row.tradeCount}) + + = 0 ? overviewSuccessText : overviewDangerText }}> + {formatUsd(row.netPnl)} + +
+
+ {row.runtimeState} +
+
+ {row.runtimeDetail} +
+
+
Account (Fallback){formatUsd(allocatedCapital)}{formatUsd(capitalUsed)} 1e-8 ? overviewDangerText : overviewSuccessText }}> + {formatUsd(remainingCapital)} + {overAllocatedCapital > 1e-8 && ( +
+ raw +{formatUsd(overAllocatedCapital)} +
+ )} +
+ {allocatedCapital > 0 ? ((capitalUsed / allocatedCapital) * 100).toFixed(1) : '0.0'}% + -= 0 ? overviewSuccessText : overviewDangerText }}>{formatUsd(displayRealizedPnl)} + No signal +
+
+ + {/* NEW: Recent Order Rejections List */} + {botState.orderFailures && botState.orderFailures.length > 0 && ( +
+

Recent Order Rejections

+
+ + + + + + + + + + + + + + {botState.orderFailures.slice(0, 10).map((fail, idx) => ( + + + + + + + + + + ))} + +
TimeSymbolSideQtyReasonSub-tagProfile
{new Date(fail.timestamp).toLocaleString()}{fail.symbol}{fail.side}{fail.qty} + {fail.reason} + + {compactTag(fail.subTag)} + {fail.profileId || 'Unknown'}
+
+
+ )} + + {/* Symbol Cards — role-aware rendering */} + {isAdminView ? ( + /* ── ADMIN: Full technical view ─────────────────── */ +
+ {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 ( +
+
+

{symbol}

+ {data.signal} +
+ {profileSignals.length > 0 && ( +
+ {profileSignals.map((ps, idx) => ( + + {(ps.profileName || `P${idx + 1}`)}: {ps.signal} + + ))} +
+ )} +
+
4H Trend Bias{bias4h}
+
1H Momentum (RSI){momentum1h} {data.indicators.rsi_1h ? `(${data.indicators.rsi_1h.toFixed(1)})` : ''}
+
Session{data.session}
+
Volatility{data.volatility}
+ {data.indicators.ema50_4h &&
EMA50 (4H){data.indicators.ema50_4h.toFixed(2)}
} + {data.indicators.ema200_4h &&
EMA200 (4H){data.indicators.ema200_4h.toFixed(2)}
} +
+
+ Can trade? + {readinessLabel} +
+
+ ); + })} +
+ ) : ( + /* ── CONSUMER: Plain-English friendly cards ────── */ +
+ {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 ( +
+ {/* Top accent line based on signal */} +
+ + {/* Header */} +
+
+
+ {symbol.split('/')[0]} + /{symbol.split('/')[1] || 'USDT'} +
+
= 0 ? overviewSuccessText : overviewDangerText, marginTop: '2px', fontFamily: 'monospace' }}> + {change24h >= 0 ? '+' : ''}{change24h.toFixed(2)}% today +
+
+
{botStatusEmoji}
+
+ + {/* Friendly metrics */} +
+ {[ + { 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 }) => ( +
+ {label} +
+
+ {value} +
+
+ ))} +
+ + {/* Bot Status Footer */} +
+
Bot Status
+
+ {botStatusLabel} +
+
+
+ ); + })} +
+ )} +
+ ); }; diff --git a/web/src/views/HomeView.tsx b/web/src/views/HomeView.tsx index 7ad3360..797d885 100644 --- a/web/src/views/HomeView.tsx +++ b/web/src/views/HomeView.tsx @@ -2,13 +2,13 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { ArrowRight, BarChart2, BarChart3, Bell, Loader2, Search, ShieldCheck, Sparkles, Star } from 'lucide-react'; import { - AreaChart, Area, Bar, ComposedChart, Line, LineChart, XAxis, YAxis, Tooltip, - ResponsiveContainer, CartesianGrid, ReferenceLine, + AreaChart, Area, Bar, ComposedChart, Line, LineChart, XAxis, YAxis, Tooltip, + ResponsiveContainer, CartesianGrid, ReferenceLine, } from 'recharts'; import { useAppContext } from '../context/AppContext'; import { - fetchChartBars, fetchResearchProfile, fetchResearchMetrics, fetchResearchEarnings, - type OHLCVBar, + fetchChartBars, fetchResearchProfile, fetchResearchMetrics, fetchResearchEarnings, + type OHLCVBar, } from '../lib/marketApi'; import { SkeletonBlock, SkeletonText } from '../components/Skeleton'; import { Button } from '../components/ui/button'; @@ -19,27 +19,27 @@ type Period = typeof PERIODS[number]; type IndicatorKey = 'rsi' | 'macd' | 'bollinger'; interface ChartPoint { - ts: number; - price: number; - label: string; - bollingerUpper?: number; - bollingerMiddle?: number; - bollingerLower?: number; - rsi?: number; - macd?: number; - macdSignal?: number; - macdHistogram?: number; + ts: number; + price: number; + label: string; + bollingerUpper?: number; + bollingerMiddle?: number; + bollingerLower?: number; + rsi?: number; + macd?: number; + macdSignal?: number; + macdHistogram?: number; } interface ResearchProfile { - companyName?: string; - sector?: string; - industry?: string; - description?: string; - website?: string; - mktCap?: number; - revenue?: number; - exchangeShortName?: string; + companyName?: string; + sector?: string; + industry?: string; + description?: string; + website?: string; + mktCap?: number; + revenue?: number; + exchangeShortName?: string; } const EQUITY_EMPTY_STATE_SYMBOLS = ['AAPL','MSFT','GOOGL','AMZN','NVDA']; @@ -50,868 +50,868 @@ const homeNegativeText = 'var(--bl-danger)'; const homeNegativeMuted = 'color-mix(in oklab, var(--bl-danger) 38%, var(--background))'; function uniqueSymbols(symbols: string[]) { - return Array.from(new Set(symbols.map(s => s.trim().toUpperCase()).filter(Boolean))); + return Array.from(new Set(symbols.map(s => s.trim().toUpperCase()).filter(Boolean))); } function splitSymbols(raw: unknown) { - if (Array.isArray(raw)) return uniqueSymbols(raw.map(String)); - if (typeof raw === 'string') return uniqueSymbols(raw.split(',')); - return []; + if (Array.isArray(raw)) return uniqueSymbols(raw.map(String)); + if (typeof raw === 'string') return uniqueSymbols(raw.split(',')); + return []; } function isCryptoLikeSymbol(symbol: string) { - const normalized = symbol.trim().toUpperCase(); - const base = normalized.split(/[/-]/)[0]; - return normalized.includes('/') || normalized.endsWith('USDT') || CRYPTO_BASES.has(base); + const normalized = symbol.trim().toUpperCase(); + const base = normalized.split(/[/-]/)[0]; + return normalized.includes('/') || normalized.endsWith('USDT') || CRYPTO_BASES.has(base); } function emptyStateSuggestions(profile: any, botSymbols: Record) { - const configuredSymbols = uniqueSymbols([ - ...splitSymbols(profile?.symbols), - ...Object.keys(botSymbols ?? {}), - ]); - if (configuredSymbols.length > 0) return configuredSymbols.slice(0, 5); + const configuredSymbols = uniqueSymbols([ + ...splitSymbols(profile?.symbols), + ...Object.keys(botSymbols ?? {}), + ]); + if (configuredSymbols.length > 0) return configuredSymbols.slice(0, 5); - const marketHint = String( - profile?.market_type ?? profile?.marketType ?? profile?.asset_class ?? profile?.assetClass ?? profile?.exchange ?? '', - ).toLowerCase(); - if (marketHint.includes('crypto')) return CRYPTO_EMPTY_STATE_SYMBOLS; + const marketHint = String( + profile?.market_type ?? profile?.marketType ?? profile?.asset_class ?? profile?.assetClass ?? profile?.exchange ?? '', + ).toLowerCase(); + if (marketHint.includes('crypto')) return CRYPTO_EMPTY_STATE_SYMBOLS; - return EQUITY_EMPTY_STATE_SYMBOLS; + return EQUITY_EMPTY_STATE_SYMBOLS; } const INDICATORS: Array<{ key: IndicatorKey; label: string; hint: string }> = [ - { key: 'rsi', label: 'RSI', hint: '14-period momentum' }, - { key: 'macd', label: 'MACD', hint: '12/26 EMA trend' }, - { key: 'bollinger', label: 'Bollinger', hint: '20-period bands' }, + { key: 'rsi', label: 'RSI', hint: '14-period momentum' }, + { key: 'macd', label: 'MACD', hint: '12/26 EMA trend' }, + { key: 'bollinger', label: 'Bollinger', hint: '20-period bands' }, ]; // ─── Helpers ────────────────────────────────────────────────────────────────── function formatPriceLabel(ts: number, period: Period) { - const d = new Date(ts); - if (period === '1D') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - if (period === '5D') return d.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' }); - return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); + const d = new Date(ts); + if (period === '1D') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + if (period === '5D') return d.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' }); + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); } function formatAsOfTimestamp(ts: number | null) { - if (ts == null) return 'Latest bar pending'; + if (ts == null) return 'Latest bar pending'; - return new Date(ts).toLocaleString('en-US', { - timeZone: 'America/New_York', - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); + return new Date(ts).toLocaleString('en-US', { + timeZone: 'America/New_York', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); } function average(values: number[]) { - return values.reduce((sum, value) => sum + value, 0) / values.length; + return values.reduce((sum, value) => sum + value, 0) / values.length; } function calculateRsi(closes: number[], period = 14): Array { - const rsi: Array = Array(closes.length).fill(undefined); - if (closes.length <= period) return rsi; - const fromAverages = (gain: number, loss: number) => { - if (gain === 0 && loss === 0) return 50; - if (loss === 0) return 100; - return 100 - (100 / (1 + gain / loss)); - }; + const rsi: Array = Array(closes.length).fill(undefined); + if (closes.length <= period) return rsi; + const fromAverages = (gain: number, loss: number) => { + if (gain === 0 && loss === 0) return 50; + if (loss === 0) return 100; + return 100 - (100 / (1 + gain / loss)); + }; - let gainSum = 0; - let lossSum = 0; - for (let i = 1; i <= period; i += 1) { - const change = closes[i] - closes[i - 1]; - gainSum += Math.max(change, 0); - lossSum += Math.max(-change, 0); - } + let gainSum = 0; + let lossSum = 0; + for (let i = 1; i <= period; i += 1) { + const change = closes[i] - closes[i - 1]; + gainSum += Math.max(change, 0); + lossSum += Math.max(-change, 0); + } - let averageGain = gainSum / period; - let averageLoss = lossSum / period; - rsi[period] = fromAverages(averageGain, averageLoss); + let averageGain = gainSum / period; + let averageLoss = lossSum / period; + rsi[period] = fromAverages(averageGain, averageLoss); - for (let i = period + 1; i < closes.length; i += 1) { - const change = closes[i] - closes[i - 1]; - averageGain = ((averageGain * (period - 1)) + Math.max(change, 0)) / period; - averageLoss = ((averageLoss * (period - 1)) + Math.max(-change, 0)) / period; - rsi[i] = fromAverages(averageGain, averageLoss); - } + for (let i = period + 1; i < closes.length; i += 1) { + const change = closes[i] - closes[i - 1]; + averageGain = ((averageGain * (period - 1)) + Math.max(change, 0)) / period; + averageLoss = ((averageLoss * (period - 1)) + Math.max(-change, 0)) / period; + rsi[i] = fromAverages(averageGain, averageLoss); + } - return rsi; + return rsi; } function calculateEma(values: number[], period: number): Array { - const ema: Array = Array(values.length).fill(undefined); - if (values.length < period) return ema; + const ema: Array = Array(values.length).fill(undefined); + if (values.length < period) return ema; - const multiplier = 2 / (period + 1); - ema[period - 1] = average(values.slice(0, period)); - for (let i = period; i < values.length; i += 1) { - ema[i] = (values[i] - ema[i - 1]!) * multiplier + ema[i - 1]!; - } - return ema; + const multiplier = 2 / (period + 1); + ema[period - 1] = average(values.slice(0, period)); + for (let i = period; i < values.length; i += 1) { + ema[i] = (values[i] - ema[i - 1]!) * multiplier + ema[i - 1]!; + } + return ema; } function lastDefined(values: Array) { - for (let i = values.length - 1; i >= 0; i -= 1) { - if (values[i] != null) return values[i]; - } - return undefined; + for (let i = values.length - 1; i >= 0; i -= 1) { + if (values[i] != null) return values[i]; + } + return undefined; } function calculateMacd(closes: number[]) { - const fast = calculateEma(closes, 12); - const slow = calculateEma(closes, 26); - const macd: Array = closes.map((_, i) => ( - fast[i] != null && slow[i] != null ? fast[i]! - slow[i]! : undefined - )); - const signal: Array = Array(closes.length).fill(undefined); - const signalPeriod = 9; - const signalMultiplier = 2 / (signalPeriod + 1); - for (let i = 0; i < macd.length; i += 1) { - if (macd[i] == null) continue; - const recentMacd = macd.slice(0, i + 1).filter((value): value is number => value != null); - if (recentMacd.length < signalPeriod) continue; - const previousSignal = signal[i - 1]; - signal[i] = previousSignal == null - ? average(recentMacd.slice(-signalPeriod)) - : (macd[i]! - previousSignal) * signalMultiplier + previousSignal; - } - const histogram = macd.map((value, i) => ( - value != null && signal[i] != null ? value - signal[i]! : undefined - )); + const fast = calculateEma(closes, 12); + const slow = calculateEma(closes, 26); + const macd: Array = closes.map((_, i) => ( + fast[i] != null && slow[i] != null ? fast[i]! - slow[i]! : undefined + )); + const signal: Array = Array(closes.length).fill(undefined); + const signalPeriod = 9; + const signalMultiplier = 2 / (signalPeriod + 1); + for (let i = 0; i < macd.length; i += 1) { + if (macd[i] == null) continue; + const recentMacd = macd.slice(0, i + 1).filter((value): value is number => value != null); + if (recentMacd.length < signalPeriod) continue; + const previousSignal = signal[i - 1]; + signal[i] = previousSignal == null + ? average(recentMacd.slice(-signalPeriod)) + : (macd[i]! - previousSignal) * signalMultiplier + previousSignal; + } + const histogram = macd.map((value, i) => ( + value != null && signal[i] != null ? value - signal[i]! : undefined + )); - return { macd, signal, histogram }; + return { macd, signal, histogram }; } function calculateBollingerBands(closes: number[], period = 20, deviations = 2) { - const upper: Array = Array(closes.length).fill(undefined); - const middle: Array = Array(closes.length).fill(undefined); - const lower: Array = Array(closes.length).fill(undefined); + const upper: Array = Array(closes.length).fill(undefined); + const middle: Array = Array(closes.length).fill(undefined); + const lower: Array = Array(closes.length).fill(undefined); - for (let i = period - 1; i < closes.length; i += 1) { - const slice = closes.slice(i - period + 1, i + 1); - const mean = average(slice); - const variance = average(slice.map(value => (value - mean) ** 2)); - const standardDeviation = Math.sqrt(variance); - middle[i] = mean; - upper[i] = mean + standardDeviation * deviations; - lower[i] = mean - standardDeviation * deviations; - } + for (let i = period - 1; i < closes.length; i += 1) { + const slice = closes.slice(i - period + 1, i + 1); + const mean = average(slice); + const variance = average(slice.map(value => (value - mean) ** 2)); + const standardDeviation = Math.sqrt(variance); + middle[i] = mean; + upper[i] = mean + standardDeviation * deviations; + lower[i] = mean - standardDeviation * deviations; + } - return { upper, middle, lower }; + return { upper, middle, lower }; } function normalizeResearchProfile(profile: any): ResearchProfile | null { - return Array.isArray(profile) ? profile[0] : profile; + return Array.isArray(profile) ? profile[0] : profile; } // ─── Ticker header ──────────────────────────────────────────────────────────── export function TickerHeader({ - symbol, - profile, - latestBarTimestamp, + symbol, + profile, + latestBarTimestamp, }: { - symbol: string; - profile?: ResearchProfile | null; - latestBarTimestamp?: number | null; + symbol: string; + profile?: ResearchProfile | null; + latestBarTimestamp?: number | null; }) { - const navigate = useNavigate(); - const { botState } = useAppContext(); - const data = botState.symbols?.[symbol]; - const price = data?.price ?? 0; - const change = data?.changeToday ?? 0; - const changePct = price > 0 ? (change / (price - change)) * 100 : 0; - const positive = change >= 0; - const company = profile?.companyName; - const companyName = typeof company === 'string' && company.trim() ? company.trim() : symbol; - const exchange = typeof profile?.exchangeShortName === 'string' && profile.exchangeShortName.trim() - ? profile.exchangeShortName.trim() - : '—'; + const navigate = useNavigate(); + const { botState } = useAppContext(); + const data = botState.symbols?.[symbol]; + const price = data?.price ?? 0; + const change = data?.changeToday ?? 0; + const changePct = price > 0 ? (change / (price - change)) * 100 : 0; + const positive = change >= 0; + const company = profile?.companyName; + const companyName = typeof company === 'string' && company.trim() ? company.trim() : symbol; + const exchange = typeof profile?.exchangeShortName === 'string' && profile.exchangeShortName.trim() + ? profile.exchangeShortName.trim() + : '—'; - return ( -
-
-

- {symbol} -

- - {companyName} - + return ( +
+
+

+ {symbol} +

+ + {companyName} + -
- - -
-
+
+ + +
+
-
- - {price > 0 ? price.toFixed(2) : '—'} - - {price > 0 && ( - - {positive ? '+' : ''}{change.toFixed(2)} ({positive ? '+' : ''}{changePct.toFixed(2)}%) - - )} -
+
+ + {price > 0 ? price.toFixed(2) : '—'} + + {price > 0 && ( + + {positive ? '+' : ''}{change.toFixed(2)} ({positive ? '+' : ''}{changePct.toFixed(2)}%) + + )} +
-
- {formatAsOfTimestamp(latestBarTimestamp ?? null)} ET · {exchange} -
-
- ); +
+ {formatAsOfTimestamp(latestBarTimestamp ?? null)} ET · {exchange} +
+
+ ); } // ─── Stock chart ────────────────────────────────────────────────────────────── function StockChart({ - symbol, - onLatestBarTimestamp, - onBarsChange, + symbol, + onLatestBarTimestamp, + onBarsChange, }: { - symbol: string; - onLatestBarTimestamp?: (timestamp: number | null) => void; - onBarsChange?: (bars: OHLCVBar[]) => void; + symbol: string; + onLatestBarTimestamp?: (timestamp: number | null) => void; + onBarsChange?: (bars: OHLCVBar[]) => void; }) { - const [period, setPeriod] = useState('1Y'); - const [bars, setBars] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [enabledIndicators, setEnabledIndicators] = useState>({ - rsi: false, - macd: false, - bollinger: false, - }); + const [period, setPeriod] = useState('1Y'); + const [bars, setBars] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [enabledIndicators, setEnabledIndicators] = useState>({ + rsi: false, + macd: false, + bollinger: false, + }); - useEffect(() => { - let cancelled = false; - setLoading(true); - setError(null); - setBars([]); - onLatestBarTimestamp?.(null); - onBarsChange?.([]); - fetchChartBars(symbol, period) - .then(data => { - if (!cancelled) { - setBars(data); - onLatestBarTimestamp?.(data.at(-1)?.ts ?? null); - onBarsChange?.(data); - } - }) - .catch(err => { if (!cancelled) setError(err?.message ?? 'Failed to load chart'); }) - .finally(() => { if (!cancelled) setLoading(false); }); - return () => { cancelled = true; }; - }, [symbol, period, onLatestBarTimestamp, onBarsChange]); + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + setBars([]); + onLatestBarTimestamp?.(null); + onBarsChange?.([]); + fetchChartBars(symbol, period) + .then(data => { + if (!cancelled) { + setBars(data); + onLatestBarTimestamp?.(data.at(-1)?.ts ?? null); + onBarsChange?.(data); + } + }) + .catch(err => { if (!cancelled) setError(err?.message ?? 'Failed to load chart'); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [symbol, period, onLatestBarTimestamp, onBarsChange]); - const closes = bars.map(b => b.close); - const rsi = calculateRsi(closes); - const macd = calculateMacd(closes); - const bollinger = calculateBollingerBands(closes); + const closes = bars.map(b => b.close); + const rsi = calculateRsi(closes); + const macd = calculateMacd(closes); + const bollinger = calculateBollingerBands(closes); - const chartData: ChartPoint[] = bars.map((b, index) => ({ - ts: b.ts, - price: b.close, - label: formatPriceLabel(b.ts, period), - bollingerUpper: bollinger.upper[index], - bollingerMiddle: bollinger.middle[index], - bollingerLower: bollinger.lower[index], - rsi: rsi[index], - macd: macd.macd[index], - macdSignal: macd.signal[index], - macdHistogram: macd.histogram[index], - })); + const chartData: ChartPoint[] = bars.map((b, index) => ({ + ts: b.ts, + price: b.close, + label: formatPriceLabel(b.ts, period), + bollingerUpper: bollinger.upper[index], + bollingerMiddle: bollinger.middle[index], + bollingerLower: bollinger.lower[index], + rsi: rsi[index], + macd: macd.macd[index], + macdSignal: macd.signal[index], + macdHistogram: macd.histogram[index], + })); - const firstPrice = chartData[0]?.price ?? 0; - const lastPrice = chartData[chartData.length - 1]?.price ?? 0; - const positive = lastPrice >= firstPrice; - const lineColor = positive ? 'var(--primary)' : 'var(--destructive)'; + const firstPrice = chartData[0]?.price ?? 0; + const lastPrice = chartData[chartData.length - 1]?.price ?? 0; + const positive = lastPrice >= firstPrice; + const lineColor = positive ? 'var(--primary)' : 'var(--destructive)'; - const priceYValues = chartData.flatMap(d => [ - d.price, - ...(enabledIndicators.bollinger - ? [d.bollingerUpper, d.bollingerMiddle, d.bollingerLower].filter((value): value is number => value != null) - : []), - ]); - const macdValues = chartData.flatMap(d => ( - [d.macd, d.macdSignal, d.macdHistogram].filter((value): value is number => value != null) - )); - const minY = priceYValues.length ? Math.min(...priceYValues) : 0; - const maxY = priceYValues.length ? Math.max(...priceYValues) : 100; - const pad = (maxY - minY) * 0.1 || 10; - const macdMaxAbs = macdValues.length ? Math.max(...macdValues.map(value => Math.abs(value))) : 1; - const enabledCount = Object.values(enabledIndicators).filter(Boolean).length; + const priceYValues = chartData.flatMap(d => [ + d.price, + ...(enabledIndicators.bollinger + ? [d.bollingerUpper, d.bollingerMiddle, d.bollingerLower].filter((value): value is number => value != null) + : []), + ]); + const macdValues = chartData.flatMap(d => ( + [d.macd, d.macdSignal, d.macdHistogram].filter((value): value is number => value != null) + )); + const minY = priceYValues.length ? Math.min(...priceYValues) : 0; + const maxY = priceYValues.length ? Math.max(...priceYValues) : 100; + const pad = (maxY - minY) * 0.1 || 10; + const macdMaxAbs = macdValues.length ? Math.max(...macdValues.map(value => Math.abs(value))) : 1; + const enabledCount = Object.values(enabledIndicators).filter(Boolean).length; - const toggleIndicator = (key: IndicatorKey) => { - setEnabledIndicators(prev => ({ ...prev, [key]: !prev[key] })); - }; + const toggleIndicator = (key: IndicatorKey) => { + setEnabledIndicators(prev => ({ ...prev, [key]: !prev[key] })); + }; - return ( -
- {/* Period selector + chart type */} -
-
- {PERIODS.map(p => ( - - ))} -
-
-
- {INDICATORS.map(indicator => { - const active = enabledIndicators[indicator.key]; - return ( - - ); - })} -
-
- Line Chart -
-
-
-
-
- Indicators: {enabledCount > 0 ? `${enabledCount} active` : 'none'} -
-
- RSI 14 · MACD 12/26/9 · Bollinger 20/2 -
-
+ return ( +
+ {/* Period selector + chart type */} +
+
+ {PERIODS.map(p => ( + + ))} +
+
+
+ {INDICATORS.map(indicator => { + const active = enabledIndicators[indicator.key]; + return ( + + ); + })} +
+
+ Line Chart +
+
+
+
+
+ Indicators: {enabledCount > 0 ? `${enabledCount} active` : 'none'} +
+
+ RSI 14 · MACD 12/26/9 · Bollinger 20/2 +
+
- {/* Chart */} - {loading ? ( -
- - Loading chart… -
- ) : error ? ( -
- - {error} -
- ) : chartData.length < 2 ? ( -
- - No price data available for {symbol} -
- ) : ( -
- - - - - - - - - - - `$${v.toFixed(0)}`} - /> - { - const labels: Record = { - price: 'Price', - bollingerUpper: 'BB Upper', - bollingerMiddle: 'BB Mid', - bollingerLower: 'BB Lower', - }; - const key = String(name ?? ''); - return [`$${Number(val).toFixed(2)}`, labels[key] ?? key]; - }} - labelStyle={{ color: 'var(--muted-foreground)', fontSize: 11 }} - /> - {enabledIndicators.bollinger && ( - <> - - - - - )} - - - + {/* Chart */} + {loading ? ( +
+ + Loading chart… +
+ ) : error ? ( +
+ + {error} +
+ ) : chartData.length < 2 ? ( +
+ + No price data available for {symbol} +
+ ) : ( +
+ + + + + + + + + + + `$${v.toFixed(0)}`} + /> + { + const labels: Record = { + price: 'Price', + bollingerUpper: 'BB Upper', + bollingerMiddle: 'BB Mid', + bollingerLower: 'BB Lower', + }; + const key = String(name ?? ''); + return [`$${Number(val).toFixed(2)}`, labels[key] ?? key]; + }} + labelStyle={{ color: 'var(--muted-foreground)', fontSize: 11 }} + /> + {enabledIndicators.bollinger && ( + <> + + + + + )} + + + - {enabledIndicators.rsi && ( -
-
- RSI (14) - 70 overbought · 30 oversold -
- - - - - - [Number(val).toFixed(1), 'RSI']} - labelStyle={{ color: 'var(--muted-foreground)', fontSize: 11 }} - /> - - - - - -
- )} + {enabledIndicators.rsi && ( +
+
+ RSI (14) + 70 overbought · 30 oversold +
+ + + + + + [Number(val).toFixed(1), 'RSI']} + labelStyle={{ color: 'var(--muted-foreground)', fontSize: 11 }} + /> + + + + + +
+ )} - {enabledIndicators.macd && ( -
-
- MACD (12, 26, 9) -
- - - - - - { - const labels: Record = { - macdHistogram: 'Histogram', - macd: 'MACD', - macdSignal: 'Signal', - }; - const key = String(name ?? ''); - return [Number(val).toFixed(3), labels[key] ?? key]; - }} - labelStyle={{ color: 'var(--muted-foreground)', fontSize: 11 }} - /> - - - - - - -
- )} -
- )} -
- ); + {enabledIndicators.macd && ( +
+
+ MACD (12, 26, 9) +
+ + + + + + { + const labels: Record = { + macdHistogram: 'Histogram', + macd: 'MACD', + macdSignal: 'Signal', + }; + const key = String(name ?? ''); + return [Number(val).toFixed(3), labels[key] ?? key]; + }} + labelStyle={{ color: 'var(--muted-foreground)', fontSize: 11 }} + /> + + + + + + +
+ )} +
+ )} +
+ ); } // ─── Quick stats cards ──────────────────────────────────────────────────────── function QuickStats({ symbol, bars }: { symbol: string; bars: OHLCVBar[] }) { - const { botState } = useAppContext(); - const d = botState.symbols?.[symbol]; - const closes = bars.map(bar => bar.close); - const fallbackRsi = lastDefined(calculateRsi(closes)); - const fallbackEma50 = lastDefined(calculateEma(closes, 50)); - const fallbackEma200 = lastDefined(calculateEma(closes, 200)); - const rsi = d?.indicators?.rsi_1h ?? fallbackRsi; - const ema50 = d?.indicators?.ema50_4h ?? fallbackEma50; - const ema200 = d?.indicators?.ema200_4h ?? fallbackEma200; + const { botState } = useAppContext(); + const d = botState.symbols?.[symbol]; + const closes = bars.map(bar => bar.close); + const fallbackRsi = lastDefined(calculateRsi(closes)); + const fallbackEma50 = lastDefined(calculateEma(closes, 50)); + const fallbackEma200 = lastDefined(calculateEma(closes, 200)); + const rsi = d?.indicators?.rsi_1h ?? fallbackRsi; + const ema50 = d?.indicators?.ema50_4h ?? fallbackEma50; + const ema200 = d?.indicators?.ema200_4h ?? fallbackEma200; - const stats = [ - { label: 'RSI (14)', value: rsi != null ? rsi.toFixed(1) : '—' }, - { label: 'EMA 50', value: ema50 != null ? ema50.toFixed(2) : '—' }, - { label: 'EMA 200', value: ema200 != null ? ema200.toFixed(2) : '—' }, - { label: 'Signal', value: d?.signal ?? '—' }, - ]; + const stats = [ + { label: 'RSI (14)', value: rsi != null ? rsi.toFixed(1) : '—' }, + { label: 'EMA 50', value: ema50 != null ? ema50.toFixed(2) : '—' }, + { label: 'EMA 200', value: ema200 != null ? ema200.toFixed(2) : '—' }, + { label: 'Signal', value: d?.signal ?? '—' }, + ]; - return ( -
- {stats.map(s => ( -
-
{s.label}
-
{s.value}
-
- ))} -
- ); + return ( +
+ {stats.map(s => ( +
+
{s.label}
+
{s.value}
+
+ ))} +
+ ); } // ─── Live research / financials cards (Phase 4) ─────────────────────────────── const fmtBig = (n: number | undefined) => { - if (n == null || n === 0) return '—'; - if (Math.abs(n) >= 1e12) return `$${(n / 1e12).toFixed(2)}T`; - if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(2)}B`; - if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(2)}M`; - return `$${n.toFixed(2)}`; + if (n == null || n === 0) return '—'; + if (Math.abs(n) >= 1e12) return `$${(n / 1e12).toFixed(2)}T`; + if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(2)}B`; + if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(2)}M`; + return `$${n.toFixed(2)}`; }; function ResearchCards({ - symbol, - profile, - profileLoading, + symbol, + profile, + profileLoading, }: { - symbol: string; - profile: ResearchProfile | null; - profileLoading: boolean; + symbol: string; + profile: ResearchProfile | null; + profileLoading: boolean; }) { - const [metrics, setMetrics] = useState(null); - const [earnings, setEarnings] = useState([]); - const [loading, setLoading] = useState(true); + const [metrics, setMetrics] = useState(null); + const [earnings, setEarnings] = useState([]); + const [loading, setLoading] = useState(true); - useEffect(() => { - let cancelled = false; - setLoading(true); - setMetrics(null); setEarnings([]); - Promise.allSettled([ - fetchResearchMetrics(symbol), - fetchResearchEarnings(symbol), - ]).then(([m, e]) => { - if (cancelled) return; - if (m.status === 'fulfilled') setMetrics(Array.isArray(m.value) ? m.value[0] : m.value); - if (e.status === 'fulfilled') setEarnings(e.value ?? []); - setLoading(false); - }); - return () => { cancelled = true; }; - }, [symbol]); + useEffect(() => { + let cancelled = false; + setLoading(true); + setMetrics(null); setEarnings([]); + Promise.allSettled([ + fetchResearchMetrics(symbol), + fetchResearchEarnings(symbol), + ]).then(([m, e]) => { + if (cancelled) return; + if (m.status === 'fulfilled') setMetrics(Array.isArray(m.value) ? m.value[0] : m.value); + if (e.status === 'fulfilled') setEarnings(e.value ?? []); + setLoading(false); + }); + return () => { cancelled = true; }; + }, [symbol]); - const nextEarnings = earnings.find(e => e.date && new Date(e.date) >= new Date()); - const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '—'; - const ValueSkeleton = ({ width = 58 }: { width?: number }) => ; + const nextEarnings = earnings.find(e => e.date && new Date(e.date) >= new Date()); + const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '—'; + const ValueSkeleton = ({ width = 58 }: { width?: number }) => ; - const financialRows: [string, string][] = [ - ['Market Cap', fmtBig(profile?.mktCap)], - ['Revenue (TTM)', fmtBig(metrics?.revenuePerShareTTM != null && metrics?.sharesWSOQuarterly != null - ? metrics.revenuePerShareTTM * metrics.sharesWSOQuarterly - : profile?.revenue ?? undefined)], - ['Net Income (TTM)', fmtBig(metrics?.netIncomePerShareTTM != null && metrics?.sharesWSOQuarterly != null - ? metrics.netIncomePerShareTTM * metrics.sharesWSOQuarterly - : undefined)], - ['P/E Ratio (TTM)', metrics?.peRatioTTM != null ? metrics.peRatioTTM.toFixed(1) : '—'], - ['ROE (TTM)', metrics?.roeTTM != null ? `${(metrics.roeTTM * 100).toFixed(1)}%` : '—'], - ]; + const financialRows: [string, string][] = [ + ['Market Cap', fmtBig(profile?.mktCap)], + ['Revenue (TTM)', fmtBig(metrics?.revenuePerShareTTM != null && metrics?.sharesWSOQuarterly != null + ? metrics.revenuePerShareTTM * metrics.sharesWSOQuarterly + : profile?.revenue ?? undefined)], + ['Net Income (TTM)', fmtBig(metrics?.netIncomePerShareTTM != null && metrics?.sharesWSOQuarterly != null + ? metrics.netIncomePerShareTTM * metrics.sharesWSOQuarterly + : undefined)], + ['P/E Ratio (TTM)', metrics?.peRatioTTM != null ? metrics.peRatioTTM.toFixed(1) : '—'], + ['ROE (TTM)', metrics?.roeTTM != null ? `${(metrics.roeTTM * 100).toFixed(1)}%` : '—'], + ]; - return ( -
- {/* Company Profile */} -
-
- 📋 Company -
- {profileLoading ? ( -
- - - - -
- ) : profile ? ( - <> -
- {profile.companyName ?? symbol} - {profile.sector && <> · {profile.sector}} - {profile.industry && <> · {profile.industry}} -
-
- {profile.description ?? ''} -
- {profile.website && ( - - {profile.website} - - )} - - ) : ( -
No profile data
- )} -
+ return ( +
+ {/* Company Profile */} +
+
+ 📋 Company +
+ {profileLoading ? ( +
+ + + + +
+ ) : profile ? ( + <> +
+ {profile.companyName ?? symbol} + {profile.sector && <> · {profile.sector}} + {profile.industry && <> · {profile.industry}} +
+
+ {profile.description ?? ''} +
+ {profile.website && ( + + {profile.website} + + )} + + ) : ( +
No profile data
+ )} +
- {/* Financials */} -
-
- 📊 Financials -
- {financialRows.map(([label, val]) => ( -
- {label} - - {loading || profileLoading ? 10 ? 64 : 46} /> : val} - -
- ))} -
+ {/* Financials */} +
+
+ 📊 Financials +
+ {financialRows.map(([label, val]) => ( +
+ {label} + + {loading || profileLoading ? 10 ? 64 : 46} /> : val} + +
+ ))} +
- {/* Events / Earnings */} -
-
- 📅 Events -
- {[ - ['Next Earnings', loading ? '…' : fmtDate(nextEarnings?.date)], - ['EPS Estimate', loading ? '…' : nextEarnings?.epsEstimated != null ? `$${nextEarnings.epsEstimated.toFixed(2)}` : '—'], - ['Revenue Est.', loading ? '…' : nextEarnings?.revenueEstimated != null ? fmtBig(nextEarnings.revenueEstimated) : '—'], - ['Exchange', profileLoading ? '…' : profile?.exchangeShortName ?? '—'], - ].map(([label, val]) => ( -
- {label} - - {val === '…' ? : val} - -
- ))} - {!loading && earnings.length > 0 && ( -
-
Past Earnings
- {earnings.slice(0,3).map((e, i) => ( -
- {fmtDate(e.date)} - = (e.epsEstimated ?? e.eps) ? homePositiveText : homeNegativeText }}> - EPS {e.eps != null ? `$${e.eps.toFixed(2)}` : '—'} - -
- ))} -
- )} -
-
- ); + {/* Events / Earnings */} +
+
+ 📅 Events +
+ {[ + ['Next Earnings', loading ? '…' : fmtDate(nextEarnings?.date)], + ['EPS Estimate', loading ? '…' : nextEarnings?.epsEstimated != null ? `$${nextEarnings.epsEstimated.toFixed(2)}` : '—'], + ['Revenue Est.', loading ? '…' : nextEarnings?.revenueEstimated != null ? fmtBig(nextEarnings.revenueEstimated) : '—'], + ['Exchange', profileLoading ? '…' : profile?.exchangeShortName ?? '—'], + ].map(([label, val]) => ( +
+ {label} + + {val === '…' ? : val} + +
+ ))} + {!loading && earnings.length > 0 && ( +
+
Past Earnings
+ {earnings.slice(0,3).map((e, i) => ( +
+ {fmtDate(e.date)} + = (e.epsEstimated ?? e.eps) ? homePositiveText : homeNegativeText }}> + EPS {e.eps != null ? `$${e.eps.toFixed(2)}` : '—'} + +
+ ))} +
+ )} +
+
+ ); } // ─── Empty state ────────────────────────────────────────────────────────────── function EmptyState({ - onSelect, - suggestions, + onSelect, + suggestions, }: { - onSelect: (symbol: string) => void; - suggestions: string[]; + onSelect: (symbol: string) => void; + suggestions: string[]; }) { - const cryptoMode = suggestions.some(isCryptoLikeSymbol); + const cryptoMode = suggestions.some(isCryptoLikeSymbol); - return ( -
-
-
-
- - Market workspace -
-

Start with a symbol. See the full trading picture.

-

- Search or pick a configured asset to open charts, fundamentals, news, alerts, and execution context in one focused workspace. -

-
- {suggestions.slice(0, 5).map(t => ( - - ))} -
- {cryptoMode && ( -
- Suggested from your crypto bot configuration -
- )} -
+ return ( +
+
+
+
+ + Market workspace +
+

Start with a symbol. See the full trading picture.

+

+ Search or pick a configured asset to open charts, fundamentals, news, alerts, and execution context in one focused workspace. +

+
+ {suggestions.slice(0, 5).map(t => ( + + ))} +
+ {cryptoMode && ( +
+ Suggested from your crypto bot configuration +
+ )} +
- -
+ +
-
- {[ - { icon: Search, title: 'Analyze', copy: 'Open a symbol workspace with chart, metrics, and news.' }, - { icon: ShieldCheck, title: 'Plan', copy: 'Build short-term setups with explicit sizing and exits.' }, - { icon: Bell, title: 'Monitor', copy: 'Track alerts, watchlist entries, and live status updates.' }, - { icon: BarChart3, title: 'Review', copy: 'Check positions, orders, and strategy performance.' }, - ].map(({ icon: Icon, title, copy }) => ( -
-
- -
-

{title}

-

{copy}

-
- ))} -
-
- ); +
+ {[ + { icon: Search, title: 'Analyze', copy: 'Open a symbol workspace with chart, metrics, and news.' }, + { icon: ShieldCheck, title: 'Plan', copy: 'Build short-term setups with explicit sizing and exits.' }, + { icon: Bell, title: 'Monitor', copy: 'Track alerts, watchlist entries, and live status updates.' }, + { icon: BarChart3, title: 'Review', copy: 'Check positions, orders, and strategy performance.' }, + ].map(({ icon: Icon, title, copy }) => ( +
+
+ +
+

{title}

+

{copy}

+
+ ))} +
+
+ ); } // ─── HomeView ───────────────────────────────────────────────────────────────── export function HomeView() { - const { activeSymbol, setActiveSymbol, botState, profile: activeProfile } = useAppContext(); - const [profile, setProfile] = useState(null); - const [profileLoading, setProfileLoading] = useState(false); - const [latestBarTimestamp, setLatestBarTimestamp] = useState(null); - const [chartBars, setChartBars] = useState([]); + const { activeSymbol, setActiveSymbol, botState, profile: activeProfile } = useAppContext(); + const [profile, setProfile] = useState(null); + const [profileLoading, setProfileLoading] = useState(false); + const [latestBarTimestamp, setLatestBarTimestamp] = useState(null); + const [chartBars, setChartBars] = useState([]); - useEffect(() => { - if (!activeSymbol) { - setProfile(null); - setProfileLoading(false); - return; - } + useEffect(() => { + if (!activeSymbol) { + setProfile(null); + setProfileLoading(false); + return; + } - let cancelled = false; - setProfile(null); - setProfileLoading(true); - fetchResearchProfile(activeSymbol) - .then(data => { - if (!cancelled) setProfile(normalizeResearchProfile(data)); - }) - .catch(() => { - if (!cancelled) setProfile(null); - }) - .finally(() => { - if (!cancelled) setProfileLoading(false); - }); - return () => { cancelled = true; }; - }, [activeSymbol]); + let cancelled = false; + setProfile(null); + setProfileLoading(true); + fetchResearchProfile(activeSymbol) + .then(data => { + if (!cancelled) setProfile(normalizeResearchProfile(data)); + }) + .catch(() => { + if (!cancelled) setProfile(null); + }) + .finally(() => { + if (!cancelled) setProfileLoading(false); + }); + return () => { cancelled = true; }; + }, [activeSymbol]); - if (!activeSymbol) { - return ( - - ); - } + if (!activeSymbol) { + return ( + + ); + } - return ( -
- - - - -
- ); + return ( +
+ + + + +
+ ); }