From a48187da836e59111924d7982cc1ee3908ce32cc Mon Sep 17 00:00:00 2001
From: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sun, 10 May 2026 09:39:31 +0000
Subject: [PATCH] refactor(web): extract repeated inline-style patterns to
classes (UI audit #8)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per-pattern, per-file analysis found 191 inline-style blocks across the
3 landing-view files (OverviewTab 72, MyStrategiesTab 64, HomeView 55).
155 are unique one-offs already using design tokens (var(--bl-*)) — those
stay inline since converting them to single-use classes would just relocate
code without reducing drift.
This commit extracts the **repeated patterns** (count >= 2 plus structural
container shapes) — 39 occurrences — into either Tailwind utilities or a
new web/src/styles/landing-views.css partial:
Tailwind replacements:
textAlign: 'right' → text-right
marginBottom: '32px' / '24px' → mb-8 / mb-6
fontWeight: 700 → font-bold
flex+justify-between+items-center → flex justify-between items-center
CSS partial (.lv-* classes, all token-driven):
.lv-card, .lv-card-lg, .lv-icon-tag, .lv-surface, .lv-eyebrow,
.lv-section-title, .lv-section-sub, .lv-empty-text,
.lv-divider-row, .lv-meta-faint
Per-file deltas:
tabs/OverviewTab.tsx 72 -> 53 inline blocks (19 conversions)
tabs/MyStrategiesTab.tsx 64 -> 55 inline blocks ( 9 conversions)
views/HomeView.tsx 55 -> 44 inline blocks (11 conversions)
The remaining 152 inline-style blocks are unique one-offs (one usage each)
and already token-driven — extracting them yields no drift reduction.
docs/ui/UI_AUDIT.md §5 #8 updated with this rationale.
Generated with [Devin](https://cli.devin.ai/docs)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
web/src/main.tsx | 1 +
web/src/styles/landing-views.css | 91 ++
web/src/tabs/MyStrategiesTab.tsx | 886 ++++++-------
web/src/tabs/OverviewTab.tsx | 1980 +++++++++++++++---------------
web/src/views/HomeView.tsx | 1542 +++++++++++------------
5 files changed, 2296 insertions(+), 2204 deletions(-)
create mode 100644 web/src/styles/landing-views.css
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 */}
-
-
onToggle(profile)}
- variant={profile.is_active ? 'outline' : 'primary'}
- style={{
- width: '100%',
- height: '56px',
- background: profile.is_active ? 'var(--bl-surface-highlight)' : 'var(--bl-success)',
- color: profile.is_active ? 'var(--bl-text-secondary)' : 'black',
- borderRadius: '18px',
- border: profile.is_active ? '1px solid var(--bl-border-subtle)' : 'none',
- fontWeight: 900,
- fontSize: '12px',
- textTransform: 'uppercase',
- cursor: 'pointer',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- gap: '12px',
- boxShadow: profile.is_active ? 'none' : '0 12px 36px -12px color-mix(in oklab, var(--bl-success) 40%, transparent)',
- transition: 'all 0.2s',
- letterSpacing: '1.5px'
- }}
- className="action-btn-hover"
- >
- {profile.is_active ? (
- <> PAUSE TRADING>
- ) : (
- <> START TRADING>
- )}
-
-
+ {/* 6. Action */}
+
+
onToggle(profile)}
+ variant={profile.is_active ? 'outline' : 'primary'}
+ style={{
+ width: '100%',
+ height: '56px',
+ background: profile.is_active ? 'var(--bl-surface-highlight)' : 'var(--bl-success)',
+ color: profile.is_active ? 'var(--bl-text-secondary)' : 'black',
+ borderRadius: '18px',
+ border: profile.is_active ? '1px solid var(--bl-border-subtle)' : 'none',
+ fontWeight: 900,
+ fontSize: '12px',
+ textTransform: 'uppercase',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '12px',
+ boxShadow: profile.is_active ? 'none' : '0 12px 36px -12px color-mix(in oklab, var(--bl-success) 40%, transparent)',
+ transition: 'all 0.2s',
+ letterSpacing: '1.5px'
+ }}
+ className="action-btn-hover"
+ >
+ {profile.is_active ? (
+ <> PAUSE TRADING>
+ ) : (
+ <> START TRADING>
+ )}
+
+
-
-
- );
+
+
+ );
};
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);
- }}
- variant="ghost"
- style={{ background: 'none', border: 'none', color: 'var(--bl-text-quiet)', fontWeight: 900, fontSize: '12px', textTransform: 'uppercase', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}
- >
- ← Back to My Strategies
-
-
-
{
- setShowWizard(false);
- setEditingProfile(null);
- fetchProfiles();
- }}
- />
-
- );
- }
+ if (showWizard) {
+ return (
+
+
+ {
+ setShowWizard(false);
+ setEditingProfile(null);
+ }}
+ variant="ghost"
+ style={{ background: 'none', border: 'none', color: 'var(--bl-text-quiet)', fontWeight: 900, fontSize: '12px', textTransform: 'uppercase', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px' }}
+ >
+ ← Back to My Strategies
+
+
+
{
+ 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'}
-
-
setShowWizard(true)}
- variant="primary"
- >
- New strategy
-
-
-
+
+
+ {botState?.connected ? 'Systems online' : 'Systems disconnected'}
+
+
setShowWizard(true)}
+ variant="primary"
+ >
+ New strategy
+
+
+
- {/* 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 */}
-
-
-
- {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 */}
+
+
+
+ {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.
-
setShowWizard(true)}
- variant="secondary"
- style={{ marginTop: '32px', background: 'white', border: 'none', color: 'black', padding: '16px 40px', borderRadius: '16px', fontWeight: 900, cursor: 'pointer' }}
- >
- GET STARTED
-
-
- )}
-
+ {profiles.length === 0 && !isLoading && (
+
+
+
No strategies yet
+
Create your first strategy to start monitoring markets and testing automated execution.
+
setShowWizard(true)}
+ variant="secondary"
+ style={{ marginTop: '32px', background: 'white', border: 'none', color: 'black', padding: '16px 40px', borderRadius: '16px', fontWeight: 900, cursor: 'pointer' }}
+ >
+ GET STARTED
+
+
+ )}
+
-
-
- );
+
+
+ );
};
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 (
- setWinRateWindow(option.key)}
- variant={active ? 'secondary' : 'ghost'}
- size="sm"
- style={{
- border: active ? overviewActiveBorder : overviewSoftBorder,
- color: active ? overviewSuccessText : overviewQuietText,
- background: active ? overviewActiveSurface : overviewMutedSurface,
- borderRadius: '8px',
- fontSize: '0.7rem',
- fontWeight: 700
- }}
- >
- {option.label}
-
- );
- })}
-
-
-
-
- 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)}...)
-
-
- )}
-
-
-
-
-
-
- Capital Scope
- Allocated
- Used
- Remaining
- Utilization
- Win Rate ({winRateWindowConfig.label})
- Realized P&L
- State
-
-
-
- {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 (
-
- {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
-
-
-
-
- Time
- Symbol
- Side
- Qty
- Reason
- Sub-tag
- Profile
-
-
-
- {botState.orderFailures.slice(0, 10).map((fail, idx) => (
-
- {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 }) => (
-
- ))}
-
-
- {/* 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 (
+ setWinRateWindow(option.key)}
+ variant={active ? 'secondary' : 'ghost'}
+ size="sm"
+ style={{
+ border: active ? overviewActiveBorder : overviewSoftBorder,
+ color: active ? overviewSuccessText : overviewQuietText,
+ background: active ? overviewActiveSurface : overviewMutedSurface,
+ borderRadius: '8px',
+ fontSize: '0.7rem',
+ fontWeight: 700
+ }}
+ >
+ {option.label}
+
+ );
+ })}
+
+
+
+
+ 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)}...)
+
+
+ )}
+
+
+
+
+
+
+ Capital Scope
+ Allocated
+ Used
+ Remaining
+ Utilization
+ Win Rate ({winRateWindowConfig.label})
+ Realized P&L
+ State
+
+
+
+ {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 (
+
+ {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
+
+
+
+
+ Time
+ Symbol
+ Side
+ Qty
+ Reason
+ Sub-tag
+ Profile
+
+
+
+ {botState.orderFailures.slice(0, 10).map((fail, idx) => (
+
+ {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 }) => (
+
+ ))}
+
+
+ {/* 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}
+
-
- navigate('/watchlist')}
- variant="outline"
- size="sm"
- style={{ borderRadius: 999 }}
- >
- Watchlist
-
- navigate('/alerts')}
- variant="outline"
- size="icon"
- style={{ borderRadius: '50%' }}
- >
-
-
-
-
+
+ navigate('/watchlist')}
+ variant="outline"
+ size="sm"
+ style={{ borderRadius: 999 }}
+ >
+ Watchlist
+
+ navigate('/alerts')}
+ variant="outline"
+ size="icon"
+ style={{ borderRadius: '50%' }}
+ >
+
+
+
+
-
-
- {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 => (
- setPeriod(p)}
- className="home-chart-toggle"
- data-active={period === p}
- aria-pressed={period === p}
- >
- {p}
-
- ))}
-
-
-
- {INDICATORS.map(indicator => {
- const active = enabledIndicators[indicator.key];
- return (
- toggleIndicator(indicator.key)}
- title={indicator.hint}
- aria-pressed={active}
- className="home-chart-toggle home-chart-toggle--pill"
- data-active={active}
- >
- {indicator.label}
-
- );
- })}
-
-
- 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 => (
+ setPeriod(p)}
+ className="home-chart-toggle"
+ data-active={period === p}
+ aria-pressed={period === p}
+ >
+ {p}
+
+ ))}
+
+
+
+ {INDICATORS.map(indicator => {
+ const active = enabledIndicators[indicator.key];
+ return (
+ toggleIndicator(indicator.key)}
+ title={indicator.hint}
+ aria-pressed={active}
+ className="home-chart-toggle home-chart-toggle--pill"
+ data-active={active}
+ >
+ {indicator.label}
+
+ );
+ })}
+
+
+ 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 => (
-
onSelect(t)}
- variant={t === suggestions[0] ? 'default' : 'subtle'}
- size="lg"
- >
- {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 => (
+
onSelect(t)}
+ variant={t === suggestions[0] ? 'default' : 'subtle'}
+ size="lg"
+ >
+ {t}
+
+
+ ))}
+
+ {cryptoMode && (
+
+ Suggested from your crypto bot configuration
+
+ )}
+
-
-
-
-
-
-
-
-
-
- Readiness
- Live
-
-
- Risk
- Guarded
-
-
- Signals
- Synced
-
-
-
-
+
+
+
+
+
+
+
+
+
+ Readiness
+ Live
+
+
+ Risk
+ Guarded
+
+
+ Signals
+ Synced
+
+
+
+
-
- {[
- { 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 (
+
+
+
+
+
+
+ );
}