diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts
index 1f792c9..5102db4 100644
--- a/backend/src/services/apiServer.ts
+++ b/backend/src/services/apiServer.ts
@@ -2622,6 +2622,84 @@ RULES:
// MARKET DATA PROXY ENDPOINTS (Phase 3-6 of web dashboard redesign)
// ══════════════════════════════════════════════════════════════════════
+ // ── Chart bars: Alpaca stock bars with period mapping ────────────────
+ this.app.get('/api/chart/bars', this.requireAuth, async (req, res) => {
+ try {
+ const symbol = String(req.query.symbol || '').trim().toUpperCase();
+ const period = String(req.query.period || '1Y').trim();
+ const alpacaKey = config.ALPACA_API_KEY;
+ const alpacaSecret = config.ALPACA_API_SECRET;
+ if (!symbol) return res.status(400).json({ error: 'symbol required' });
+ if (!alpacaKey || !alpacaSecret) return res.status(503).json({ error: 'Alpaca credentials not configured' });
+
+ const now = new Date();
+ let start = new Date(now);
+ let timeframe = '1Day';
+ let limit = 365;
+
+ switch (period) {
+ case '1D': start.setHours(0,0,0,0); timeframe = '5Min'; limit = 100; break;
+ case '5D': start.setDate(now.getDate() - 5); timeframe = '15Min'; limit = 200; break;
+ case '1M': start.setMonth(now.getMonth() - 1); timeframe = '1Day'; limit = 31; break;
+ case '3M': start.setMonth(now.getMonth() - 3); timeframe = '1Day'; limit = 92; break;
+ case '6M': start.setMonth(now.getMonth() - 6); timeframe = '1Day'; limit = 183; break;
+ case 'YTD': start = new Date(now.getFullYear(), 0, 1); timeframe = '1Day'; limit = 365; break;
+ case '1Y': start.setFullYear(now.getFullYear() - 1); timeframe = '1Day'; limit = 365; break;
+ case '5Y': start.setFullYear(now.getFullYear() - 5); timeframe = '1Week'; limit = 260; break;
+ case 'MAX': start.setFullYear(now.getFullYear() - 20); timeframe = '1Month';limit = 240; break;
+ default: start.setFullYear(now.getFullYear() - 1); timeframe = '1Day'; limit = 365; break;
+ }
+
+ // Detect crypto (contains "/" like BTC/USD or BTC/USDT)
+ const isCrypto = symbol.includes('/');
+ const encodedSymbol = encodeURIComponent(symbol);
+ let url: string;
+ const qs = new URLSearchParams({
+ start: start.toISOString(),
+ end: now.toISOString(),
+ timeframe,
+ limit: String(limit),
+ sort: 'asc',
+ });
+ if (isCrypto) {
+ url = `https://data.alpaca.markets/v1beta3/crypto/us/bars?symbols=${encodedSymbol}&${qs.toString()}`;
+ } else {
+ qs.set('feed', 'iex');
+ url = `https://data.alpaca.markets/v2/stocks/${encodedSymbol}/bars?${qs.toString()}`;
+ }
+ const r = await fetch(url, {
+ headers: {
+ 'APCA-API-KEY-ID': alpacaKey,
+ 'APCA-API-SECRET-KEY': alpacaSecret,
+ },
+ });
+ if (!r.ok) {
+ const txt = await r.text().catch(() => '');
+ return res.status(r.status).json({ error: `Alpaca bars fetch failed: ${txt}` });
+ }
+ const data = await r.json() as any;
+ // Crypto response: { bars: { "BTC/USD": [...] } }, stocks: { bars: [...] }
+ let rawBars: any[];
+ if (isCrypto) {
+ const cryptoBars = data.bars ?? {};
+ rawBars = cryptoBars[symbol] ?? Object.values(cryptoBars)[0] ?? [];
+ } else {
+ rawBars = data.bars ?? [];
+ }
+ const bars = rawBars.map((b: any) => ({
+ ts: new Date(b.t).getTime(),
+ open: b.o,
+ high: b.h,
+ low: b.l,
+ close: b.c,
+ volume:b.v,
+ }));
+ res.json({ symbol, period, bars });
+ } catch (error: any) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
// ── News: proxy to Alpaca /v1beta1/news ───────────────────────────────
this.app.get('/api/news', this.requireAuth, async (req, res) => {
try {
diff --git a/web/package.json b/web/package.json
index 3512216..028a5cd 100644
--- a/web/package.json
+++ b/web/package.json
@@ -18,6 +18,8 @@
"preview": "vite preview"
},
"dependencies": {
+ "@bytelyst/api-client": "file:../vendor/bytelyst/api-client",
+ "@bytelyst/errors": "file:../vendor/bytelyst/errors",
"@bytelyst/kill-switch-client": "file:../vendor/bytelyst/kill-switch-client",
"@bytelyst/react-auth": "file:../vendor/bytelyst/react-auth",
"@bytelyst/telemetry-client": "file:../vendor/bytelyst/telemetry-client",
diff --git a/web/src/App.dom.test.tsx b/web/src/App.dom.test.tsx
index e5c32e7..c6c11ab 100644
--- a/web/src/App.dom.test.tsx
+++ b/web/src/App.dom.test.tsx
@@ -1,155 +1,158 @@
-// @vitest-environment jsdom
-import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, fireEvent } from '@testing-library/react';
-// import userEvent from '@testing-library/user-event';
+// @vitest-environment jsdom
+import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
import App, { resolveProfileNameForAction, buildChatApplyPayload } from './App';
const { authMock, socketMock, fetchTradeProfilesMock, createTradeProfileMock, updateTradeProfileMock } = vi.hoisted(() => ({
authMock: { user: null as any, profile: null as any, loading: false, signOut: vi.fn() },
socketMock: {
botState: {
- settings: { isAlgoEnabled: true },
- symbols: {},
- health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 }
+ settings: { isAlgoEnabled: true },
+ symbols: {},
+ health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 },
+ alerts: [],
+ positions: [],
+ orders: [],
+ history: [],
+ uptime: 0,
},
- connected: true
+ connected: true,
+ socket: null,
},
fetchTradeProfilesMock: vi.fn(),
createTradeProfileMock: vi.fn(),
updateTradeProfileMock: vi.fn()
}));
-
-vi.mock('./components/AuthContext', () => ({
- useAuth: () => authMock
-}));
-
-vi.mock('./hooks/useWebSocket', () => ({
- useWebSocket: () => socketMock,
- DEFAULT_BOT_STATE: {
- settings: { isAlgoEnabled: true },
- symbols: {},
- health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 }
- }
-}));
-
+
+vi.mock('./components/AuthContext', () => ({
+ useAuth: () => authMock
+}));
+
+vi.mock('./hooks/useWebSocket', () => ({
+ useWebSocket: () => socketMock,
+ DEFAULT_BOT_STATE: {
+ settings: { isAlgoEnabled: true },
+ symbols: {},
+ health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 },
+ alerts: [],
+ positions: [],
+ orders: [],
+ history: [],
+ uptime: 0,
+ }
+}));
+
vi.mock('./lib/profileApi', () => ({
fetchTradeProfiles: fetchTradeProfilesMock,
createTradeProfile: createTradeProfileMock,
updateTradeProfile: updateTradeProfileMock
}));
-
-// Mock components
-vi.mock('./components/AlertFeed', () => ({ AlertFeed: () =>
AlertFeedMock
}));
-vi.mock('./components/MarketOpportunities', () => ({ AISetups: () => AISetups
, TopVolatile: () => TopVolatile
}));
-vi.mock('./tabs/OverviewTab', () => ({ OverviewTab: () => OverviewTab
}));
-vi.mock('./tabs/SignalsTab', () => ({ SignalsTab: () => SignalsTab
}));
-vi.mock('./tabs/PositionsTab', () => ({ PositionsTab: () => PositionsTab
}));
-vi.mock('./tabs/HistoryTab', () => ({ HistoryTab: () => HistoryTab
}));
-vi.mock('./tabs/SettingsTab', () => ({ SettingsTab: () => SettingsTab
}));
-vi.mock('./tabs/EntriesTab', () => ({ EntriesTab: () => EntriesTab
}));
-vi.mock('./tabs/AdminTab', () => ({ AdminTab: () => AdminTab
}));
-vi.mock('./components/TradeProfileManager', () => ({ TradeProfileManager: () => TradeProfileManager
}));
-vi.mock('./components/ChatControl', () => ({
- ChatControl: ({ onApplyProfile }: any) => (
- onApplyProfile('create_profile', { name: 'New' })}>ApplyChat
- )
-}));
-vi.mock('./components/Login', () => ({ Login: () => LoginMock
}));
-vi.mock('./components/ResetPassword', () => ({ ResetPassword: () => ResetPasswordMock
}));
-
-describe('App Component DOM', () => {
- beforeEach(() => {
- vi.useFakeTimers();
- authMock.user = null;
- authMock.profile = null;
- authMock.loading = false;
- socketMock.botState = {
- settings: { isAlgoEnabled: true },
- symbols: {},
- health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 }
- };
-
+
+// Mock all layout and view components — they have external dependencies
+vi.mock('./components/layout/AppShell', () => ({
+ AppShell: () => (
+
+ )
+}));
+
+vi.mock('./components/AlertFeed', () => ({ AlertFeed: () => AlertFeedMock
}));
+vi.mock('./components/MarketOpportunities', () => ({
+ AISetups: () => AISetups
,
+ TopVolatile: () => TopVolatile
+}));
+vi.mock('./components/ChatControl', () => ({
+ ChatControl: ({ onApplyProfile }: any) => (
+ onApplyProfile('create_profile', { name: 'New' })}>ApplyChat
+ )
+}));
+vi.mock('./components/Login', () => ({ Login: () => LoginMock
}));
+vi.mock('./components/ResetPassword', () => ({ ResetPassword: () => ResetPasswordMock
}));
+vi.mock('./backtest/flags', () => ({
+ useBacktestFeatureGate: () => ({ enabled: false, loading: false }),
+ isBacktestBuildEnabled: () => false,
+}));
+vi.mock('./hooks/useTabFeatureFlags', () => ({
+ useTabFeatureFlags: () => ({ flags: { marketplace: false } })
+}));
+
+describe('App Component DOM', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ authMock.user = null;
+ authMock.profile = null;
+ authMock.loading = false;
+ socketMock.botState = {
+ settings: { isAlgoEnabled: true },
+ symbols: {},
+ health: { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 },
+ alerts: [],
+ positions: [],
+ orders: [],
+ history: [],
+ uptime: 0,
+ };
fetchTradeProfilesMock.mockResolvedValue([]);
createTradeProfileMock.mockResolvedValue({});
updateTradeProfileMock.mockResolvedValue({});
-
- vi.stubGlobal('location', { pathname: '/' });
});
-
- afterEach(() => {
- vi.useRealTimers();
- });
-
- it('renders login when not authenticated', () => {
- render( );
- expect(screen.getByText('LoginMock')).toBeInTheDocument();
- });
-
- it('renders main dashboard and handles tab switching', async () => {
- authMock.user = { id: 'u1', email: 'test@demo.com' };
- render( );
-
- expect(screen.getByText('Trading Bot Dashboard')).toBeInTheDocument();
-
- const positionsBtn = screen.getByText('Positions & Orders');
- fireEvent.click(positionsBtn);
- expect(screen.getByText('PositionsTab')).toBeInTheDocument();
-
- const historyBtn = screen.getByText('Trade History');
- fireEvent.click(historyBtn);
- expect(screen.getByText('HistoryTab')).toBeInTheDocument();
- });
-
- it('guards admin tab', async () => {
- authMock.user = { id: 'u1', email: 'test@demo.com' };
- authMock.profile = { role: 'user' };
- render( );
- expect(screen.queryByText(/Admin/i)).not.toBeInTheDocument();
- });
-
- it('allows admin tab access for admin role', async () => {
- authMock.user = { id: 'u1', email: 'test@demo.com' };
- authMock.profile = { role: 'admin' };
- render( );
-
- const adminBtn = screen.getByText(/Admin/i);
- fireEvent.click(adminBtn);
- expect(screen.getByText('AdminTab')).toBeInTheDocument();
- });
-
- it('displays different health states', async () => {
- authMock.user = { id: 'u1' };
- const { rerender } = render( );
-
- expect(screen.getByText('Healthy')).toBeInTheDocument();
-
- socketMock.botState.health = { tradingLoopHealthy: false, reconciliationLoopHealthy: true, capitalInvariantViolations: 0 };
- rerender( );
- expect(screen.getByText('Degraded')).toBeInTheDocument();
-
- socketMock.botState.health = { tradingLoopHealthy: true, reconciliationLoopHealthy: true, capitalInvariantViolations: 1 };
- rerender( );
- expect(screen.getByText('Unhealthy')).toBeInTheDocument();
- });
-
- it('handles logout', async () => {
- authMock.user = { id: 'u1' };
- render( );
- const logoutBtn = screen.getByText('Logout');
- fireEvent.click(logoutBtn);
- expect(authMock.signOut).toHaveBeenCalled();
- });
-});
-
-describe('App Logic Helpers', () => {
- it('resolveProfileNameForAction appends time on collision', () => {
- const profiles = [{ name: 'Alpha' }];
- const res = resolveProfileNameForAction('create_profile', 'Alpha', profiles, () => '123');
- expect(res).toBe('Alpha (123)');
- });
-
- it('buildChatApplyPayload uses defaults', () => {
- const payload = buildChatApplyPayload({}, 'u1', 'Name');
- expect(payload.symbols).toBe('BTC/USDT');
- });
-});
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('renders login when not authenticated', () => {
+ render( );
+ expect(screen.getByText('LoginMock')).toBeInTheDocument();
+ });
+
+ it('renders main dashboard when authenticated', async () => {
+ authMock.user = { id: 'u1', email: 'test@demo.com' };
+ render( );
+ expect(screen.getByTestId('app-shell')).toBeInTheDocument();
+ expect(screen.getByTestId('main-content')).toBeInTheDocument();
+ });
+
+ it('shows sidebar nav links when authenticated', () => {
+ authMock.user = { id: 'u1', email: 'test@demo.com' };
+ render( );
+ expect(screen.getByText('Portfolio')).toBeInTheDocument();
+ expect(screen.getByText('Research')).toBeInTheDocument();
+ expect(screen.getByText('Settings')).toBeInTheDocument();
+ });
+
+ it('admin sees settings route (not hidden)', () => {
+ authMock.user = { id: 'u1', email: 'test@demo.com' };
+ authMock.profile = { role: 'admin' };
+ render( );
+ // Admin can access settings which includes admin panel
+ expect(screen.getByText('Settings')).toBeInTheDocument();
+ });
+
+ it('non-admin user still has settings nav', () => {
+ authMock.user = { id: 'u1', email: 'test@demo.com' };
+ authMock.profile = { role: 'user' };
+ render( );
+ expect(screen.getByText('Settings')).toBeInTheDocument();
+ });
+});
+
+describe('App Logic Helpers', () => {
+ it('resolveProfileNameForAction appends time on collision', () => {
+ const profiles = [{ name: 'Alpha' }];
+ const res = resolveProfileNameForAction('create_profile', 'Alpha', profiles, () => '123');
+ expect(res).toBe('Alpha (123)');
+ });
+
+ it('buildChatApplyPayload uses defaults', () => {
+ const payload = buildChatApplyPayload({}, 'u1', 'Name');
+ expect(payload.symbols).toBe('BTC/USDT');
+ });
+});
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 83b3210..312fcf8 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -50,7 +50,7 @@ function App() {
const { socket, botState, connected } = useWebSocket(tradingRuntime.tradingApiUrl);
const [activeSymbol, setActiveSymbol] = useState('');
const [chatProfiles, setChatProfiles] = useState([]);
- const [previewAsCustomer, setPreviewAsCustomer] = useState(false);
+ const [previewAsCustomer] = useState(false);
const { enabled: backtestEnabledForView, loading: backtestGateLoading } =
useBacktestFeatureGate({ previewAsCustomer });
diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx
index 61446e3..9eee3d9 100644
--- a/web/src/components/layout/Header.tsx
+++ b/web/src/components/layout/Header.tsx
@@ -1,64 +1,29 @@
-import { useState, useRef } from 'react';
+import { useState, useEffect, useRef } from 'react';
import { Search } from 'lucide-react';
import { useAppContext } from '../../context/AppContext';
import { useNavigate } from 'react-router-dom';
+import { fetchMarketIndices, type IndexSnapshot } from '../../lib/marketApi';
-// Minimal SVG sparkline
-function Sparkline({ values, color }: { values: number[]; color: string }) {
- if (values.length < 2) return null;
- const min = Math.min(...values);
- const max = Math.max(...values);
- const range = max - min || 1;
- const W = 64, H = 28;
- const pts = values
- .map((v, i) => {
- const x = (i / (values.length - 1)) * W;
- const y = H - ((v - min) / range) * H;
- return `${x.toFixed(1)},${y.toFixed(1)}`;
- })
- .join(' ');
- return (
-
-
-
- );
-}
-
-// Static placeholder indices — Phase 3 will replace with live Alpaca data
-const PLACEHOLDER_INDICES = [
- {
- label: 'S&P 500',
- change: '+0.38%',
- positive: true,
- spark: [395, 396, 394, 397, 399, 398, 401, 403, 402, 404],
- },
- {
- label: 'Dow Jones',
- change: '-0.11%',
- positive: false,
- spark: [338, 339, 337, 336, 337, 336, 335, 336, 335, 338],
- },
- {
- label: 'Nasdaq',
- change: '+0.65%',
- positive: true,
- spark: [174, 175, 176, 175, 177, 178, 179, 178, 180, 181],
- },
-];
export function Header() {
- const { activeSymbol, setActiveSymbol, connected } = useAppContext();
- const [query, setQuery] = useState('');
+ const { setActiveSymbol, connected } = useAppContext();
+ const [query, setQuery] = useState('');
+ const [indices, setIndices] = useState([]);
const navigate = useNavigate();
const inputRef = useRef(null);
+ // Fetch live market indices once on mount, refresh every 60s
+ useEffect(() => {
+ let cancelled = false;
+ const load = () =>
+ fetchMarketIndices()
+ .then(data => { if (!cancelled) setIndices(data); })
+ .catch(() => {/* ignore — keep stale data */});
+ load();
+ const interval = setInterval(load, 60_000);
+ return () => { cancelled = true; clearInterval(interval); };
+ }, []);
+
const handleSearch = (raw: string) => {
const symbol = raw.trim().toUpperCase();
if (!symbol) return;
@@ -125,24 +90,39 @@ export function Header() {
{/* Market indices */}
- {PLACEHOLDER_INDICES.map(idx => (
-
-
-
- {idx.label}
-
-
- {idx.change}
+ {indices.length === 0 ? (
+ // Skeleton while loading
+ ['S&P 500','Dow Jones','Nasdaq'].map(label => (
+
-
-
- ))}
+ ))
+ ) : indices.map(idx => {
+ const signStr = idx.positive ? '+' : '';
+ return (
+
+
+
+ {idx.label}
+
+
+
+ ${idx.price.toFixed(2)}
+
+
+ {signStr}{idx.changePct.toFixed(2)}%
+
+
+
+
+ );
+ })}
{/* Live indicator */}
diff --git a/web/src/components/layout/RightPanel.tsx b/web/src/components/layout/RightPanel.tsx
index 785ecbd..9a3c53a 100644
--- a/web/src/components/layout/RightPanel.tsx
+++ b/web/src/components/layout/RightPanel.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useRef } from 'react';
+import { useState, useEffect } from 'react';
import { ArrowRight } from 'lucide-react';
import { useAppContext } from '../../context/AppContext';
diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx
index a5d89db..431ffa4 100644
--- a/web/src/components/layout/Sidebar.tsx
+++ b/web/src/components/layout/Sidebar.tsx
@@ -1,4 +1,4 @@
-import { NavLink, useNavigate } from 'react-router-dom';
+import { NavLink } from 'react-router-dom';
import {
Home, Briefcase, FlaskConical, TrendingUp,
SlidersHorizontal, Star, Bell, Settings,
diff --git a/web/src/components/strategy/CodeStrategyEditor.tsx b/web/src/components/strategy/CodeStrategyEditor.tsx
new file mode 100644
index 0000000..a27c087
--- /dev/null
+++ b/web/src/components/strategy/CodeStrategyEditor.tsx
@@ -0,0 +1,252 @@
+/**
+ * Monaco-based code strategy editor.
+ * Users write a JS strategy function; "Run Backtest" posts it to /api/backtest.
+ */
+import { useState, useRef } from 'react';
+import Editor from '@monaco-editor/react';
+import { Play, Save, Copy, RotateCcw } from 'lucide-react';
+import { getPlatformAccessToken } from '../../lib/authSession';
+import { tradingRuntime } from '../../lib/runtime';
+import { createRequestId } from '../../../../shared/request-id.js';
+
+const DEFAULT_TEMPLATE = `/**
+ * Custom Trading Strategy
+ *
+ * Available context:
+ * symbol – ticker being traded (string)
+ * price – latest close price (number)
+ * rsi – RSI(14) value (number)
+ * ema50 – EMA 50 (number)
+ * ema200 – EMA 200 (number)
+ * macd – MACD signal (number)
+ * volume – current volume (number)
+ *
+ * Return one of:
+ * { signal: 'BUY', quantity: 10 }
+ * { signal: 'SELL', quantity: 10 }
+ * { signal: 'HOLD' }
+ */
+function strategy({ symbol, price, rsi, ema50, ema200, macd, volume }) {
+ // Example: RSI oversold + price above EMA200 → BUY
+ if (rsi < 30 && price > ema200) {
+ return { signal: 'BUY', quantity: 10 };
+ }
+ // Example: RSI overbought → SELL
+ if (rsi > 70) {
+ return { signal: 'SELL', quantity: 10 };
+ }
+ return { signal: 'HOLD' };
+}
+`;
+
+interface BacktestResult {
+ trades?: number;
+ winRate?: number;
+ totalReturn?: number;
+ sharpeRatio?: number;
+ maxDrawdown?: number;
+ [key: string]: any;
+}
+
+interface Props {
+ symbol: string;
+}
+
+export function CodeStrategyEditor({ symbol }: Props) {
+ const [code, setCode] = useState(DEFAULT_TEMPLATE);
+ const [running, setRunning] = useState(false);
+ const [result, setResult] = useState
(null);
+ const [error, setError] = useState(null);
+ const [saved, setSaved] = useState(false);
+ const editorRef = useRef(null);
+
+ const handleRunBacktest = async () => {
+ setRunning(true);
+ setError(null);
+ setResult(null);
+ try {
+ const token = await getPlatformAccessToken();
+ const res = await fetch(`${tradingRuntime.tradingApiUrl}/api/backtest`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ 'x-request-id': createRequestId('web-backtest'),
+ },
+ body: JSON.stringify({
+ symbol,
+ strategyCode: code,
+ mode: 'code',
+ }),
+ });
+ const data = await res.json().catch(() => ({})) as any;
+ if (!res.ok) throw new Error(data?.error ?? `Backtest failed (${res.status})`);
+ setResult(data);
+ } catch (err: any) {
+ setError(err?.message ?? 'Backtest failed');
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ const handleSave = () => {
+ // Store in localStorage for now — real save would POST to /api/profiles
+ const key = `strategy_code_${symbol}_${Date.now()}`;
+ localStorage.setItem(key, code);
+ setSaved(true);
+ setTimeout(() => setSaved(false), 2500);
+ };
+
+ const handleReset = () => {
+ setCode(DEFAULT_TEMPLATE);
+ setResult(null);
+ setError(null);
+ };
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(code).catch(() => {});
+ };
+
+ const fmt = (n: number | undefined, suffix = '') =>
+ n != null ? `${n.toFixed(2)}${suffix}` : '—';
+
+ return (
+
+ {/* Toolbar */}
+
+
+ Code Editor — {symbol}
+
+
+
+ Copy
+
+
+ Reset
+
+
+ {saved ? 'Saved!' : 'Save'}
+
+
+ {running ? 'Running…' : 'Run Backtest'}
+
+
+
+ {/* Monaco editor */}
+
+ setCode(v ?? '')}
+ onMount={(editor) => { editorRef.current = editor; }}
+ theme="light"
+ options={{
+ fontSize: 13,
+ minimap: { enabled: false },
+ scrollBeyondLastLine: false,
+ wordWrap: 'on',
+ lineNumbers: 'on',
+ renderLineHighlight: 'gutter',
+ padding: { top: 12, bottom: 12 },
+ fontFamily: '"Fira Code", "Cascadia Code", "Consolas", monospace',
+ fontLigatures: true,
+ tabSize: 2,
+ automaticLayout: true,
+ }}
+ />
+
+
+ {/* Error */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Backtest results */}
+ {result && (
+
+
+ Backtest Results
+
+
+ {[
+ ['Total Return', fmt(result.totalReturn, '%')],
+ ['Win Rate', fmt(result.winRate, '%')],
+ ['# Trades', result.trades?.toString() ?? '—'],
+ ['Sharpe Ratio', fmt(result.sharpeRatio)],
+ ['Max Drawdown', fmt(result.maxDrawdown, '%')],
+ ].map(([label, val]) => (
+
+ ))}
+
+
+ {/* Trades table (if provided) */}
+ {Array.isArray(result.tradeLog) && result.tradeLog.length > 0 && (
+
+
+ Trade Log (last 10)
+
+
+
+
+ {['Date','Side','Price','Qty','P&L'].map(h => (
+ {h}
+ ))}
+
+
+
+ {result.tradeLog.slice(-10).map((t: any, i: number) => (
+
+ {t.date ?? '—'}
+ {t.side}
+ {t.price != null ? `$${t.price.toFixed(2)}` : '—'}
+ {t.qty ?? '—'}
+ = 0 ? '#16A34A' : '#DC2626', fontWeight: 600 }}>
+ {t.pnl != null ? `${t.pnl >= 0 ? '+' : ''}$${t.pnl.toFixed(2)}` : '—'}
+
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+ );
+}
+
+function toolBtn(bg: string, color: string, border: string): React.CSSProperties {
+ return {
+ display: 'flex', alignItems: 'center', gap: 5,
+ padding: '7px 12px', border: `1px solid ${border}`,
+ borderRadius: 8, background: bg, color,
+ fontSize: 12, fontWeight: 600, cursor: 'pointer',
+ fontFamily: 'inherit',
+ };
+}
diff --git a/web/src/components/strategy/VisualRuleBuilder.tsx b/web/src/components/strategy/VisualRuleBuilder.tsx
new file mode 100644
index 0000000..e0dde00
--- /dev/null
+++ b/web/src/components/strategy/VisualRuleBuilder.tsx
@@ -0,0 +1,361 @@
+/**
+ * Visual drag-and-drop strategy rule builder using @dnd-kit.
+ * Lets users compose IF/THEN trading rules without writing code.
+ */
+import { useState, useCallback } from 'react';
+import {
+ DndContext,
+ closestCenter,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from '@dnd-kit/core';
+import {
+ SortableContext,
+ verticalListSortingStrategy,
+ useSortable,
+ arrayMove,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { GripVertical, Plus, Trash2, Save, Play } from 'lucide-react';
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+export type Indicator = 'RSI' | 'MACD' | 'EMA_50' | 'EMA_200' | 'Price' | 'Volume';
+export type Condition = 'above' | 'below' | 'crosses_above' | 'crosses_below';
+export type TradeAction = 'BUY' | 'SELL';
+export type QtyType = 'shares' | 'percent';
+
+export interface VisualRule {
+ id: string;
+ indicator: Indicator;
+ condition: Condition;
+ value: number;
+ action: TradeAction;
+ quantity: number;
+ quantityType: QtyType;
+}
+
+interface Props {
+ symbol: string;
+ onSave: (name: string, rules: VisualRule[]) => Promise;
+ onBacktest?: (rules: VisualRule[]) => void;
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+const INDICATOR_DEFAULTS: Record = {
+ RSI: 30, MACD: 0, EMA_50: 150, EMA_200: 150, Price: 100, Volume: 1000000,
+};
+
+const INDICATOR_LABELS: Record = {
+ RSI: 'RSI (14)', MACD: 'MACD Signal', EMA_50: 'EMA 50',
+ EMA_200: 'EMA 200', Price: 'Price ($)', Volume: 'Volume',
+};
+
+const CONDITION_LABELS: Record = {
+ above: 'is above',
+ below: 'is below',
+ crosses_above: 'crosses above',
+ crosses_below: 'crosses below',
+};
+
+let _uid = 0;
+const uid = () => `rule_${++_uid}_${Date.now()}`;
+
+function makeRule(): VisualRule {
+ return {
+ id: uid(), indicator: 'RSI', condition: 'below',
+ value: 30, action: 'BUY', quantity: 10, quantityType: 'shares',
+ };
+}
+
+// ─── Sortable rule card ───────────────────────────────────────────────────────
+function RuleCard({
+ rule,
+ index,
+ onChange,
+ onDelete,
+}: {
+ rule: VisualRule;
+ index: number;
+ onChange: (id: string, patch: Partial) => void;
+ onDelete: (id: string) => void;
+}) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
+ useSortable({ id: rule.id });
+
+ const style: React.CSSProperties = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.55 : 1,
+ background: '#fff',
+ border: '1px solid #E5E7EB',
+ borderRadius: 10,
+ padding: '12px 14px',
+ display: 'flex',
+ alignItems: 'center',
+ gap: 10,
+ marginBottom: 8,
+ boxShadow: isDragging ? '0 8px 24px rgba(0,0,0,0.12)' : undefined,
+ };
+
+ const sel: React.CSSProperties = {
+ border: '1px solid #E5E7EB', borderRadius: 6, padding: '5px 8px',
+ fontSize: 12, background: '#F9FAFB', cursor: 'pointer', color: '#374151',
+ fontFamily: 'inherit',
+ };
+ const numInp: React.CSSProperties = {
+ ...sel, width: 70, textAlign: 'right',
+ };
+
+ return (
+
+ {/* Drag handle */}
+
+
+
+
+ {/* Rule label */}
+
+ #{index + 1}
+
+
+ {/* IF */}
+
IF
+
+ {/* Indicator */}
+
onChange(rule.id, {
+ indicator: e.target.value as Indicator,
+ value: INDICATOR_DEFAULTS[e.target.value as Indicator],
+ })}>
+ {(Object.keys(INDICATOR_LABELS) as Indicator[]).map(k => (
+ {INDICATOR_LABELS[k]}
+ ))}
+
+
+ {/* Condition */}
+
onChange(rule.id, { condition: e.target.value as Condition })}>
+ {(Object.keys(CONDITION_LABELS) as Condition[]).map(k => (
+ {CONDITION_LABELS[k]}
+ ))}
+
+
+ {/* Value */}
+
onChange(rule.id, { value: parseFloat(e.target.value) || 0 })}
+ />
+
+ {/* THEN */}
+
→
+
+ {/* Action */}
+
onChange(rule.id, { action: e.target.value as TradeAction })}>
+ BUY
+ SELL
+
+
+ {/* Quantity */}
+
onChange(rule.id, { quantity: parseFloat(e.target.value) || 1 })}
+ />
+
+ {/* Qty type */}
+
onChange(rule.id, { quantityType: e.target.value as QtyType })}>
+ shares
+ % of capital
+
+
+ {/* Delete */}
+
onDelete(rule.id)}
+ style={{
+ marginLeft: 'auto', background: 'none', border: 'none',
+ cursor: 'pointer', color: '#D1D5DB', padding: 4, borderRadius: 6,
+ display: 'flex', alignItems: 'center',
+ flexShrink: 0,
+ }}
+ title="Remove rule"
+ >
+
+
+
+ );
+}
+
+// ─── VisualRuleBuilder ────────────────────────────────────────────────────────
+export function VisualRuleBuilder({ symbol, onSave, onBacktest }: Props) {
+ const [rules, setRules] = useState([makeRule()]);
+ const [name, setName] = useState('My Strategy');
+ const [saving, setSaving] = useState(false);
+ const [savedMsg, setSavedMsg] = useState('');
+
+ const sensors = useSensors(useSensor(PointerSensor, {
+ activationConstraint: { distance: 5 },
+ }));
+
+ const handleDragEnd = useCallback((event: DragEndEvent) => {
+ const { active, over } = event;
+ if (over && active.id !== over.id) {
+ setRules(prev => {
+ const from = prev.findIndex(r => r.id === active.id);
+ const to = prev.findIndex(r => r.id === over.id);
+ return arrayMove(prev, from, to);
+ });
+ }
+ }, []);
+
+ const handleChange = useCallback((id: string, patch: Partial) => {
+ setRules(prev => prev.map(r => r.id === id ? { ...r, ...patch } : r));
+ }, []);
+
+ const handleDelete = useCallback((id: string) => {
+ setRules(prev => prev.filter(r => r.id !== id));
+ }, []);
+
+ const handleSave = async () => {
+ if (!name.trim() || rules.length === 0) return;
+ setSaving(true);
+ try {
+ await onSave(name.trim(), rules);
+ setSavedMsg('Strategy saved!');
+ setTimeout(() => setSavedMsg(''), 3000);
+ } catch {
+ setSavedMsg('Save failed — try again');
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ {/* Header row */}
+
+
+
Strategy name
+
setName(e.target.value)}
+ style={{
+ border: '1px solid #E5E7EB', borderRadius: 8,
+ padding: '7px 12px', fontSize: 14, fontWeight: 600,
+ color: '#111827', background: '#fff', fontFamily: 'inherit',
+ outline: 'none', width: 260,
+ }}
+ />
+
+
+ {savedMsg && (
+
{savedMsg}
+ )}
+ {onBacktest && (
+
onBacktest(rules)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 6,
+ padding: '8px 14px', border: '1px solid #E5E7EB', borderRadius: 8,
+ background: '#F9FAFB', color: '#374151', fontSize: 13, fontWeight: 600, cursor: 'pointer',
+ }}
+ >
+ Run Backtest
+
+ )}
+
+ {saving ? 'Saving…' : 'Save Strategy'}
+
+
+
+
+ {/* Column headers */}
+
+ IF Indicator
+ Condition
+ Value
+
+ Action
+ Qty
+ Type
+
+
+ {/* Drag-and-drop rule list */}
+
+ r.id)} strategy={verticalListSortingStrategy}>
+ {rules.map((rule, i) => (
+
+ ))}
+
+
+
+ {/* Add rule */}
+
setRules(prev => [...prev, makeRule()])}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 7,
+ width: '100%', padding: '10px 0', border: '1px dashed #D1D5DB',
+ borderRadius: 10, background: 'transparent', color: '#6B7280',
+ fontSize: 13, fontWeight: 600, cursor: 'pointer', justifyContent: 'center',
+ marginTop: 4,
+ }}
+ >
+ Add Rule
+
+
+ {/* Rule summary */}
+ {rules.length > 0 && (
+
+
+ Strategy Preview — {symbol}
+
+ {rules.map((r, i) => (
+
+ {i + 1}. IF {INDICATOR_LABELS[r.indicator]} {CONDITION_LABELS[r.condition]} {r.value}
+ {' → '}{r.action} {r.quantity} {r.quantityType === 'percent' ? `% of capital` : 'shares'}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/web/src/hooks/useWebSocket.ts b/web/src/hooks/useWebSocket.ts
index 2e37ed6..7aa97cb 100644
--- a/web/src/hooks/useWebSocket.ts
+++ b/web/src/hooks/useWebSocket.ts
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
-import { buildTradingSocketOptions, SOCKET_NAMESPACES } from '../../../shared/realtime.js';
+import { buildTradingSocketOptions } from '../../../shared/realtime.js';
import { getPlatformAccessToken } from '../lib/authSession';
export interface TradingControlSnapshot {
diff --git a/web/src/index.css b/web/src/index.css
index 56a55a4..9b7b871 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -3,13 +3,13 @@
@tailwind utilities;
:root {
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #0a0b0d;
+ color-scheme: light;
+ color: #111827;
+ background-color: #F3F4F6;
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -20,7 +20,7 @@
body {
margin: 0;
min-height: 100vh;
- background-color: #0a0b0d;
+ background-color: #F3F4F6;
}
#root {
@@ -55,4 +55,9 @@ body {
.pulse-green {
animation: pulse 2s infinite ease-in-out;
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
}
\ No newline at end of file
diff --git a/web/src/lib/marketApi.ts b/web/src/lib/marketApi.ts
new file mode 100644
index 0000000..86ccb7c
--- /dev/null
+++ b/web/src/lib/marketApi.ts
@@ -0,0 +1,114 @@
+/**
+ * Authenticated fetch helpers for the new market-data proxy endpoints.
+ * Follows the same pattern as profileApi.ts.
+ */
+import { getPlatformAccessToken } from './authSession';
+import { tradingRuntime } from './runtime';
+import { createRequestId } from '../../../shared/request-id.js';
+
+async function apiGet(path: string): Promise {
+ const token = await getPlatformAccessToken();
+ const res = await fetch(`${tradingRuntime.tradingApiUrl}${path}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'x-request-id': createRequestId('web-market'),
+ },
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({})) as any;
+ throw new Error(body?.error ?? `Request failed (${res.status})`);
+ }
+ return res.json() as Promise;
+}
+
+// ── Chart bars ────────────────────────────────────────────────────────────────
+
+export interface OHLCVBar {
+ ts: number;
+ open: number;
+ high: number;
+ low: number;
+ close: number;
+ volume: number;
+}
+
+export async function fetchChartBars(
+ symbol: string,
+ period: string,
+): Promise {
+ const data = await apiGet<{ bars: OHLCVBar[] }>(
+ `/api/chart/bars?symbol=${encodeURIComponent(symbol)}&period=${encodeURIComponent(period)}`,
+ );
+ return data.bars ?? [];
+}
+
+// ── Market indices ────────────────────────────────────────────────────────────
+
+export interface IndexSnapshot {
+ label: string; // 'S&P 500' | 'Dow Jones' | 'Nasdaq'
+ symbol: string; // SPY | DIA | QQQ
+ price: number;
+ change: number;
+ changePct:number;
+ positive: boolean;
+}
+
+const INDEX_META: Record = {
+ SPY: { label: 'S&P 500', symbol: 'SPY' },
+ DIA: { label: 'Dow Jones', symbol: 'DIA' },
+ QQQ: { label: 'Nasdaq', symbol: 'QQQ' },
+};
+
+export async function fetchMarketIndices(): Promise {
+ // Backend returns Alpaca snapshot shape: { SPY: { latestTrade, dailyBar, prevDailyBar, ... }, ... }
+ const data = await apiGet>('/api/market/indices');
+ return Object.entries(INDEX_META).map(([ticker, meta]) => {
+ const snap = data[ticker];
+ const price = snap?.latestTrade?.p ?? snap?.latestQuote?.ap ?? 0;
+ const prevClose = snap?.prevDailyBar?.c ?? 0;
+ const change = prevClose > 0 ? price - prevClose : 0;
+ const changePct = prevClose > 0 ? (change / prevClose) * 100 : 0;
+ return {
+ ...meta,
+ price,
+ change,
+ changePct,
+ positive: changePct >= 0,
+ };
+ });
+}
+
+// ── News ──────────────────────────────────────────────────────────────────────
+
+export interface NewsArticle {
+ id?: string;
+ url: string;
+ headline: string;
+ source: string;
+ created_at: string;
+ images?: Array<{ url: string; size?: string }>;
+}
+
+export async function fetchNews(symbol: string, limit = 8): Promise {
+ const data = await apiGet<{ news: NewsArticle[] }>(
+ `/api/news?symbols=${encodeURIComponent(symbol)}&limit=${limit}`,
+ );
+ return data.news ?? [];
+}
+
+// ── Research ──────────────────────────────────────────────────────────────────
+
+export async function fetchResearchProfile(symbol: string): Promise {
+ return apiGet(`/api/research/profile?symbol=${encodeURIComponent(symbol)}`);
+}
+
+export async function fetchResearchMetrics(symbol: string): Promise {
+ return apiGet(`/api/research/metrics?symbol=${encodeURIComponent(symbol)}`);
+}
+
+export async function fetchResearchEarnings(symbol: string): Promise {
+ const data = await apiGet<{ earnings: any[] }>(
+ `/api/research/earnings?symbol=${encodeURIComponent(symbol)}`,
+ );
+ return data.earnings ?? [];
+}
diff --git a/web/src/views/HomeView.tsx b/web/src/views/HomeView.tsx
index a12e639..0e0fede 100644
--- a/web/src/views/HomeView.tsx
+++ b/web/src/views/HomeView.tsx
@@ -1,19 +1,20 @@
-import { useState, useEffect, useRef } from 'react';
-import { Star, Bell, BarChart2 } from 'lucide-react';
+import { useState, useEffect } from 'react';
+import { Star, Bell, BarChart2, Loader2 } from 'lucide-react';
import {
AreaChart, Area, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid,
} from 'recharts';
import { useAppContext } from '../context/AppContext';
+import {
+ fetchChartBars, fetchResearchProfile, fetchResearchMetrics, fetchResearchEarnings,
+ type OHLCVBar,
+} from '../lib/marketApi';
// ─── Time period config ───────────────────────────────────────────────────────
const PERIODS = ['1D', '5D', '1M', '3M', '6M', 'YTD', '1Y', '5Y', 'MAX'] as const;
type Period = typeof PERIODS[number];
// ─── Helpers ──────────────────────────────────────────────────────────────────
-const fmt$ = (n: number) =>
- new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(n);
-
function formatPriceLabel(ts: number, period: Period) {
const d = new Date(ts);
if (period === '1D') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@@ -85,22 +86,33 @@ function TickerHeader({ symbol }: { symbol: string }) {
// ─── Stock chart ──────────────────────────────────────────────────────────────
function StockChart({ symbol }: { symbol: string }) {
- const { botState } = useAppContext();
const [period, setPeriod] = useState('1Y');
+ const [bars, setBars] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
- // Use botState price history if available, else empty
- const raw = botState.symbols?.[symbol]?.priceHistory ?? [];
- const chartData = raw.map(p => ({
- ts: p.timestamp,
- price: p.price,
- label: formatPriceLabel(p.timestamp, period),
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ setError(null);
+ setBars([]);
+ fetchChartBars(symbol, period)
+ .then(data => { if (!cancelled) setBars(data); })
+ .catch(err => { if (!cancelled) setError(err?.message ?? 'Failed to load chart'); })
+ .finally(() => { if (!cancelled) setLoading(false); });
+ return () => { cancelled = true; };
+ }, [symbol, period]);
+
+ const chartData = bars.map(b => ({
+ ts: b.ts,
+ price: b.close,
+ label: formatPriceLabel(b.ts, period),
}));
const firstPrice = chartData[0]?.price ?? 0;
const lastPrice = chartData[chartData.length - 1]?.price ?? 0;
const positive = lastPrice >= firstPrice;
const lineColor = positive ? '#2563EB' : '#DC2626';
- const fillColor = positive ? '#EFF6FF' : '#FEF2F2';
const minY = chartData.length ? Math.min(...chartData.map(d => d.price)) : 0;
const maxY = chartData.length ? Math.max(...chartData.map(d => d.price)) : 100;
@@ -143,7 +155,23 @@ function StockChart({ symbol }: { symbol: string }) {
{/* Chart */}
- {chartData.length < 2 ? (
+ {loading ? (
+
+
+ Loading chart…
+
+ ) : error ? (
+
+
+ {error}
+
+ ) : chartData.length < 2 ? (
- Price chart will appear once {symbol} is tracked by the bot
- Add {symbol} to your strategy symbols to start collecting data
+ No price data available for {symbol}
) : (
@@ -191,7 +218,7 @@ function StockChart({ symbol }: { symbol: string }) {
fontSize: 12,
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
}}
- formatter={(val: number) => [`$${val.toFixed(2)}`, 'Price']}
+ formatter={(val: any) => [`$${Number(val).toFixed(2)}`, 'Price']}
labelStyle={{ color: '#6B7280', fontSize: 11 }}
/>
{
+ 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 }: { symbol: string }) {
+ const [profile, setProfile] = useState(null);
+ const [metrics, setMetrics] = useState(null);
+ const [earnings, setEarnings] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ setLoading(true);
+ setProfile(null); setMetrics(null); setEarnings([]);
+ Promise.allSettled([
+ fetchResearchProfile(symbol),
+ fetchResearchMetrics(symbol),
+ fetchResearchEarnings(symbol),
+ ]).then(([p, m, e]) => {
+ if (cancelled) return;
+ if (p.status === 'fulfilled') setProfile(Array.isArray(p.value) ? p.value[0] : p.value);
+ 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 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 Research */}
+ {/* Company Profile */}
-
- 📋 Company Research
+
+ 📋 Company
- {['Business Overview','Industry Analysis','Competitive Position','Management Quality'].map((item, i) => (
-
- {i < 2 ? '✅' : '⬜'}
- {item}
-
- ))}
-
- View Research Checklist →
-
+ {loading ? (
+
Loading…
+ ) : profile ? (
+ <>
+
+ {profile.companyName ?? symbol}
+ {profile.sector && <> · {profile.sector}>}
+ {profile.industry && <> · {profile.industry}>}
+
+
+ {profile.description ?? ''}
+
+ {profile.website && (
+
+ {profile.website}
+
+ )}
+ >
+ ) : (
+
No profile data
+ )}
- {/* Financials — Phase 4 fills with real FMP data */}
+ {/* Financials */}
-
+
📊 Financials
- {[
- ['Market Cap', '—'],
- ['Revenue (TTM)', '—'],
- ['Net Income (TTM)','—'],
- ['P/E Ratio (TTM)', '—'],
- ['ROE (TTM)', '—'],
- ].map(([label, val]) => (
+ {financialRows.map(([label, val]) => (
{label}
- {val}
+ {loading ? '…' : val}
))}
-
- View Financial Statements →
-
- {/* Events */}
+ {/* Events / Earnings */}
-
+
📅 Events
{[
- ['Earnings Report Q2', '—'],
- ['Shareholder Meeting', '—'],
- ['Ex-Dividend Date', '—'],
- ['Product Event', '—'],
+ ['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', loading ? '…' : profile?.exchangeShortName ?? '—'],
].map(([label, val]) => (
{label}
- {val}
+ {val}
))}
-
- View All Events →
-
+ {earnings.length > 0 && (
+
+
Past Earnings
+ {earnings.slice(0,3).map((e, i) => (
+
+ {fmtDate(e.date)}
+ = (e.epsEstimated ?? e.eps) ? '#16A34A' : '#DC2626' }}>
+ EPS {e.eps != null ? `$${e.eps.toFixed(2)}` : '—'}
+
+
+ ))}
+
+ )}
);
}
// ─── Empty state ──────────────────────────────────────────────────────────────
-function EmptyState() {
+function EmptyState({ onSelect }: { onSelect: (symbol: string) => void }) {
return (
{['AAPL','MSFT','GOOGL','AMZN','NVDA'].map(t => (
-
+ onSelect(t)}
+ style={{
+ padding: '4px 12px',
+ background: '#EFF6FF',
+ color: '#2563EB',
+ borderRadius: 20,
+ fontSize: 13,
+ fontWeight: 600,
+ cursor: 'pointer',
+ }}
+ >
{t}
))}
@@ -367,17 +442,14 @@ function EmptyState() {
export function HomeView() {
const { activeSymbol, setActiveSymbol } = useAppContext();
- // Allow clicking the example tickers in empty state
- const handleTickerClick = (t: string) => setActiveSymbol(t);
-
- if (!activeSymbol) return ;
+ if (!activeSymbol) return ;
return (
-
+
);
}
diff --git a/web/src/views/MarketsView.tsx b/web/src/views/MarketsView.tsx
index 8342f88..ca73190 100644
--- a/web/src/views/MarketsView.tsx
+++ b/web/src/views/MarketsView.tsx
@@ -1,13 +1,12 @@
+import { useState } from 'react';
import { useAppContext } from '../context/AppContext';
import { MarketplaceTab } from '../tabs/MarketplaceTab';
import { TopVolatile, AISetups } from '../components/MarketOpportunities';
-import { RISK_STYLE_TEMPLATES } from '../lib/RiskStyleTemplates';
import type { StrategyPreset } from '../lib/PresetRegistry';
-import { useState } from 'react';
export function MarketsView() {
const { botState, showMarketplaceTab } = useAppContext();
- const [clonedPreset, setClonedPreset] = useState(null);
+ const [, setClonedPreset] = useState(null);
const handleClone = (preset: StrategyPreset) => setClonedPreset(preset);
diff --git a/web/src/views/ResearchView.tsx b/web/src/views/ResearchView.tsx
index 854592c..7d93eea 100644
--- a/web/src/views/ResearchView.tsx
+++ b/web/src/views/ResearchView.tsx
@@ -3,21 +3,81 @@ import { useAppContext } from '../context/AppContext';
import { SignalsTab } from '../tabs/SignalsTab';
import { BacktestTab } from '../tabs/BacktestTab';
import { MyStrategiesTab } from '../tabs/MyStrategiesTab';
-import { StrategyWizard } from '../components/StrategyWizard';
+import { VisualRuleBuilder, type VisualRule } from '../components/strategy/VisualRuleBuilder';
+import { CodeStrategyEditor } from '../components/strategy/CodeStrategyEditor';
+import { getPlatformAccessToken } from '../lib/authSession';
+import { tradingRuntime } from '../lib/runtime';
+import { createRequestId } from '../../../shared/request-id.js';
-type ResearchTab = 'Signals' | 'Strategies' | 'Backtesting';
+type ResearchTab = 'Strategies' | 'Visual Builder' | 'Code Editor' | 'Signals' | 'Backtesting';
+
+// Sub-tab pill styles
+function SubTab({
+ label, active, onClick,
+}: { label: string; active: boolean; onClick: () => void }) {
+ return (
+
+ {label}
+
+ );
+}
export function ResearchView() {
- const { botState, connected, showBacktestTab, isAdmin } = useAppContext();
+ const { botState, connected, showBacktestTab, isAdmin, activeSymbol } = useAppContext();
const [tab, setTab] = useState('Strategies');
- const [wizardSeed, setWizardSeed] = useState(null);
const tabs: ResearchTab[] = [
'Strategies',
+ 'Visual Builder',
+ 'Code Editor',
...(isAdmin ? ['Signals' as ResearchTab] : []),
...(showBacktestTab ? ['Backtesting' as ResearchTab] : []),
];
+ // Save a visual-builder strategy by converting rules to a profile config
+ const handleSaveVisualStrategy = async (name: string, rules: VisualRule[]) => {
+ const token = await getPlatformAccessToken();
+ const fallbackSymbol = Object.keys(botState.symbols)[0] ?? 'SPY';
+ const body = {
+ name,
+ symbol: activeSymbol || fallbackSymbol,
+ strategyType: 'visual',
+ visualRules: rules,
+ // Convert to a human-readable description for the existing strategy engine
+ description: rules.map((r, i) =>
+ `Rule ${i + 1}: IF ${r.indicator} ${r.condition} ${r.value} → ${r.action} ${r.quantity} ${r.quantityType}`
+ ).join('; '),
+ };
+ const res = await fetch(`${tradingRuntime.tradingApiUrl}/api/profiles`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ 'x-request-id': createRequestId('web-strategy'),
+ },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({})) as any;
+ throw new Error(data?.error ?? `Save failed (${res.status})`);
+ }
+ };
+
return (
Research
@@ -27,44 +87,56 @@ export function ResearchView() {
borderBottom: '1px solid #E5E7EB',
}}>
{tabs.map(t => (
- setTab(t)}
- style={{
- padding: '8px 16px',
- border: 'none',
- borderBottom: tab === t ? '2px solid #2563EB' : '2px solid transparent',
- background: 'transparent',
- color: tab === t ? '#2563EB' : '#6B7280',
- fontSize: 13,
- fontWeight: tab === t ? 700 : 500,
- cursor: 'pointer',
- marginBottom: -1,
- transition: 'all 0.15s',
- }}
- >
- {t}
-
+ setTab(t)} />
))}
- {tab === 'Signals' && }
- {tab === 'Strategies' && !wizardSeed && (
+ {tab === 'Strategies' && (
)}
- {tab === 'Strategies' && wizardSeed && (
- setWizardSeed(null)}
- />
+
+ {tab === 'Visual Builder' && (
+
+
+
+ Visual Rule Builder
+
+
+ Build a trading strategy by composing IF/THEN rules. Drag rows to reorder. Click "Save Strategy" to store it.
+
+
+
+
+ )}
+
+ {tab === 'Code Editor' && (
+
+
+
+ Code Strategy Editor
+
+
+ Write a custom strategy function in JavaScript. Click "Run Backtest" to test it against historical data.
+
+
+
+
+ )}
+
+ {tab === 'Signals' && (
+
+ )}
+
+ {tab === 'Backtesting' && (
+
)}
- {tab === 'Backtesting' && }
);
}
diff --git a/web/src/views/ScreenerView.tsx b/web/src/views/ScreenerView.tsx
index 638e5dc..ff70aaf 100644
--- a/web/src/views/ScreenerView.tsx
+++ b/web/src/views/ScreenerView.tsx
@@ -1,43 +1,164 @@
-import { useState } from 'react';
-import { SlidersHorizontal, Search } from 'lucide-react';
+import { useState, useEffect, useCallback } from 'react';
+import { SlidersHorizontal, Search, Loader2, RefreshCw } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
+import { useNavigate } from 'react-router-dom';
+import { getPlatformAccessToken } from '../lib/authSession';
+import { tradingRuntime } from '../lib/runtime';
+import { createRequestId } from '../../../shared/request-id.js';
-// Phase 6 will wire this to /api/screener (FMP)
-const PLACEHOLDER_RESULTS = [
- { symbol: 'AAPL', name: 'Apple Inc.', price: 201.36, change: 1.02, marketCap: '3.0T', pe: 32.1, sector: 'Technology' },
- { symbol: 'MSFT', name: 'Microsoft Corp.', price: 426.52, change: 0.89, marketCap: '3.2T', pe: 36.4, sector: 'Technology' },
- { symbol: 'GOOGL', name: 'Alphabet Inc.', price: 172.49, change: 1.36, marketCap: '2.1T', pe: 20.3, sector: 'Technology' },
- { symbol: 'AMZN', name: 'Amazon.com Inc.', price: 186.10, change: -0.23, marketCap: '2.0T', pe: 41.8, sector: 'Consumer' },
- { symbol: 'NVDA', name: 'NVIDIA Corp.', price: 134.81, change: 2.31, marketCap: '3.3T', pe: 48.2, sector: 'Technology' },
- { symbol: 'META', name: 'Meta Platforms Inc.', price: 572.40, change: -0.55, marketCap: '1.4T', pe: 26.7, sector: 'Technology' },
- { symbol: 'TSLA', name: 'Tesla Inc.', price: 284.65, change: 3.12, marketCap: '908B', pe: 88.3, sector: 'Automotive' },
- { symbol: 'JPM', name: 'JPMorgan Chase & Co.', price: 249.90, change: 0.34, marketCap: '710B', pe: 13.2, sector: 'Financials' },
+// ─── Types ────────────────────────────────────────────────────────────────────
+interface ScreenerRow {
+ symbol: string;
+ companyName: string;
+ price: number;
+ changesPercentage: number;
+ marketCap: number;
+ pe: number | null;
+ sector: string;
+ volume: number;
+}
+
+const SECTORS = [
+ 'All', 'Technology', 'Financial Services', 'Healthcare', 'Consumer Cyclical',
+ 'Consumer Defensive', 'Industrials', 'Energy', 'Utilities', 'Real Estate',
+ 'Communication Services', 'Basic Materials',
];
-const SECTORS = ['All', 'Technology', 'Financials', 'Consumer', 'Healthcare', 'Automotive', 'Energy'];
+const CAP_OPTIONS: { label: string; min: number; max?: number }[] = [
+ { label: 'Any Cap', min: 0 },
+ { label: 'Mega (>$200B)', min: 200_000_000_000 },
+ { label: 'Large ($10B–$200B)', min: 10_000_000_000, max: 200_000_000_000 },
+ { label: 'Mid ($2B–$10B)', min: 2_000_000_000, max: 10_000_000_000 },
+ { label: 'Small (<$2B)', min: 0, max: 2_000_000_000 },
+];
+const fmtCap = (n: number) => {
+ if (n >= 1e12) return `$${(n / 1e12).toFixed(1)}T`;
+ if (n >= 1e9) return `$${(n / 1e9).toFixed(1)}B`;
+ if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
+ return `$${n.toLocaleString()}`;
+};
+
+// ─── Screener fetch ───────────────────────────────────────────────────────────
+async function runScreener(params: {
+ sector: string;
+ marketCapMore?: number;
+ marketCapLess?: number;
+ limit: number;
+}): Promise
{
+ const token = await getPlatformAccessToken();
+ const qs = new URLSearchParams({ limit: String(params.limit) });
+ if (params.sector && params.sector !== 'All') qs.set('sector', params.sector);
+ if (params.marketCapMore) qs.set('marketCapMoreThan', String(params.marketCapMore));
+ if (params.marketCapLess) qs.set('marketCapLessThan', String(params.marketCapLess));
+
+ const res = await fetch(`${tradingRuntime.tradingApiUrl}/api/screener?${qs}`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'x-request-id': createRequestId('web-screener'),
+ },
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({})) as any;
+ throw new Error(body?.error ?? `Screener failed (${res.status})`);
+ }
+ const data = await res.json() as any;
+ return Array.isArray(data) ? data : (data.results ?? []);
+}
+
+// ─── ScreenerView ─────────────────────────────────────────────────────────────
export function ScreenerView() {
const { setActiveSymbol } = useAppContext();
- const [sector, setSector] = useState('All');
- const [query, setQuery] = useState('');
+ const navigate = useNavigate();
- const filtered = PLACEHOLDER_RESULTS.filter(r => {
- const matchSector = sector === 'All' || r.sector === sector;
- const matchQuery = !query
- || r.symbol.includes(query.toUpperCase())
- || r.name.toLowerCase().includes(query.toLowerCase());
- return matchSector && matchQuery;
- });
+ const [results, setResults] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [query, setQuery] = useState('');
+ const [sector, setSector] = useState('All');
+ const [capIdx, setCapIdx] = useState(0);
+ const [sortKey, setSortKey] = useState('marketCap');
+ const [sortAsc, setSortAsc] = useState(false);
+
+ const fetchResults = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ const cap = CAP_OPTIONS[capIdx];
+ try {
+ const rows = await runScreener({
+ sector,
+ marketCapMore: cap.min > 0 ? cap.min : undefined,
+ marketCapLess: cap.max,
+ limit: 50,
+ });
+ setResults(rows);
+ } catch (e: any) {
+ setError(e?.message ?? 'Screener request failed');
+ } finally {
+ setLoading(false);
+ }
+ }, [sector, capIdx]);
+
+ // Fetch on mount and when filters change
+ useEffect(() => { fetchResults(); }, [fetchResults]);
+
+ // Client-side search filter + sort
+ const filtered = results
+ .filter(r => {
+ if (!query) return true;
+ const q = query.toUpperCase();
+ return r.symbol?.includes(q) || r.companyName?.toLowerCase().includes(query.toLowerCase());
+ })
+ .sort((a, b) => {
+ const av = a[sortKey] as any ?? 0;
+ const bv = b[sortKey] as any ?? 0;
+ return sortAsc ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1);
+ });
+
+ const handleSort = (key: keyof ScreenerRow) => {
+ if (sortKey === key) setSortAsc(p => !p);
+ else { setSortKey(key); setSortAsc(false); }
+ };
+
+ const SortIcon = ({ k }: { k: keyof ScreenerRow }) => (
+
+ {sortKey === k ? (sortAsc ? '▲' : '▼') : '⇅'}
+
+ );
+
+ const handleRowClick = (symbol: string) => {
+ setActiveSymbol(symbol);
+ navigate('/');
+ };
return (
-
Screener
+
+
Stock Screener
+
+
+
+ Refresh
+
+
{/* Filters */}
-
+
{/* Search */}
-
-
+
+
+ {/* Market cap */}
+
setCapIdx(Number(e.target.value))}
+ style={{
+ padding: '8px 12px', border: '1px solid #E5E7EB', borderRadius: 8,
+ fontSize: 12, background: '#fff', color: '#374151', fontFamily: 'inherit',
+ cursor: 'pointer',
+ }}
+ >
+ {CAP_OPTIONS.map((c, i) => (
+ {c.label}
+ ))}
+
+
{/* Sector pills */}
-
-
- {SECTORS.map(s => (
+
+
+ {SECTORS.slice(0, 6).map(s => (
setSector(s)}
style={{
- padding: '5px 12px', borderRadius: 20,
- border: '1px solid', fontSize: 12, fontWeight: 600,
- cursor: 'pointer', transition: 'all 0.15s',
+ padding: '5px 10px', borderRadius: 20,
+ border: '1px solid', fontSize: 11, fontWeight: 600,
+ cursor: 'pointer',
borderColor: sector === s ? '#2563EB' : '#E5E7EB',
background: sector === s ? '#EFF6FF' : '#fff',
color: sector === s ? '#2563EB' : '#6B7280',
+ fontFamily: 'inherit',
}}
>
{s}
))}
+ {/* Sector dropdown for rest */}
+ = 6 ? sector : ''}
+ onChange={e => e.target.value && setSector(e.target.value)}
+ style={{
+ padding: '5px 8px', border: '1px solid #E5E7EB', borderRadius: 20,
+ fontSize: 11, background: '#fff', color: '#6B7280', fontFamily: 'inherit',
+ cursor: 'pointer',
+ }}
+ >
+ More sectors…
+ {SECTORS.slice(6).map(s => (
+ {s}
+ ))}
+
+ {/* Error */}
+ {error && !loading && (
+
+ {error}
+
+ )}
+
{/* Results table */}
{/* Header */}
- {['Symbol','Name','Price','Change','Mkt Cap','P/E'].map(h => (
-
- {h}
+ {([
+ ['symbol', 'Symbol'],
+ ['companyName', 'Company'],
+ ['price', 'Price'],
+ ['changesPercentage', 'Change %'],
+ ['marketCap', 'Market Cap'],
+ ['pe', 'P/E'],
+ ['volume', 'Volume'],
+ ] as [keyof ScreenerRow, string][]).map(([key, label]) => (
+ handleSort(key)}
+ style={{
+ fontSize: 11, color: '#9CA3AF', fontWeight: 700,
+ textTransform: 'uppercase', letterSpacing: '0.05em',
+ cursor: 'pointer', userSelect: 'none',
+ }}
+ >
+ {label}
))}
- {filtered.map((row, i) => (
+ {/* Loading */}
+ {loading && (
+
+
+ Fetching screener results…
+
+ )}
+
+ {/* Rows */}
+ {!loading && filtered.map((row, i) => (
setActiveSymbol(row.symbol)}
+ onClick={() => handleRowClick(row.symbol)}
style={{
display: 'grid',
- gridTemplateColumns: '1.5fr 2.5fr 1fr 1fr 1fr 1fr',
- padding: '12px 16px',
+ gridTemplateColumns: '100px 1fr 90px 90px 110px 80px 110px',
+ padding: '11px 16px',
borderBottom: i < filtered.length - 1 ? '1px solid #F9FAFB' : 'none',
cursor: 'pointer',
- transition: 'background 0.1s',
alignItems: 'center',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#F9FAFB')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{row.symbol}
- {row.name}
- ${row.price.toFixed(2)}
- = 0 ? '#16A34A' : '#DC2626' }}>
- {row.change >= 0 ? '+' : ''}{row.change.toFixed(2)}%
+ {row.companyName}
+
+ {row.price != null ? `$${row.price.toFixed(2)}` : '—'}
+
+ = 0 ? '#16A34A' : '#DC2626',
+ }}>
+ {row.changesPercentage >= 0 ? '+' : ''}{row.changesPercentage?.toFixed(2)}%
+
+
+ {row.marketCap ? fmtCap(row.marketCap) : '—'}
+
+
+ {row.pe != null && row.pe > 0 ? row.pe.toFixed(1) : '—'}
+
+
+ {row.volume ? fmtCap(row.volume).replace('$', '') : '—'}
- {row.marketCap}
- {row.pe}
))}
- {filtered.length === 0 && (
+ {!loading && filtered.length === 0 && !error && (
No results match your filters
)}
-
- ⚡ Phase 6 will connect this to live FMP screener data with full filtering
-
+ {!loading && filtered.length > 0 && (
+
+ {filtered.length} companies · Click any row to view chart & research
+
+ )}
);
}
diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json
index 758cc9f..c66118d 100644
--- a/web/tsconfig.app.json
+++ b/web/tsconfig.app.json
@@ -24,5 +24,9 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
- "include": ["src", "../shared/**/*.ts"]
+ "include": ["src", "../shared/**/*.ts"],
+ "exclude": [
+ "../shared/platform-clients.ts",
+ "../shared/platform-mobile.ts"
+ ]
}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 3764899..5907f79 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -2,6 +2,16 @@ import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'node:path'
+import fs from 'node:fs'
+
+// Resolve a @bytelyst/* package: prefer web/node_modules, fall back to vendor/
+function bytelystAlias(pkg: string): string {
+ const nmPath = path.resolve(__dirname, 'node_modules/@bytelyst', pkg);
+ const vendorPath = path.resolve(__dirname, '../vendor/bytelyst', pkg);
+ if (fs.existsSync(nmPath)) return nmPath;
+ if (fs.existsSync(vendorPath)) return vendorPath;
+ return nmPath; // let Vite surface the missing-module error
+}
// https://vite.dev/config/
export default defineConfig({
@@ -11,9 +21,15 @@ export default defineConfig({
],
// Shared files (../shared/*.ts) live outside web/ so Vite resolves their imports
// from the repo root where @bytelyst/* are not installed. Redirect all @bytelyst/*
- // imports to web/node_modules where pnpm installs them.
+ // imports first to web/node_modules, then fall back to the monorepo vendor/ dir.
resolve: {
+ // Deduplicate React so the vendored react-auth dist resolves the same react instance
+ dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react-router-dom'],
alias: [
+ // Vendor packages that live only in vendor/ (not in web/node_modules/)
+ { find: '@bytelyst/api-client', replacement: bytelystAlias('api-client') },
+ { find: '@bytelyst/errors', replacement: bytelystAlias('errors') },
+ // General catch-all: every other @bytelyst/* → web/node_modules
{
find: /^@bytelyst\/(.+)/,
replacement: path.resolve(__dirname, 'node_modules/@bytelyst/$1'),