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) => ( - - ) -})); -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: () => ( +
+ +
Dashboard Content
+
+ ) +})); + +vi.mock('./components/AlertFeed', () => ({ AlertFeed: () =>
AlertFeedMock
})); +vi.mock('./components/MarketOpportunities', () => ({ + AISetups: () =>
AISetups
, + TopVolatile: () =>
TopVolatile
+})); +vi.mock('./components/ChatControl', () => ({ + ChatControl: ({ onApplyProfile }: any) => ( + + ) +})); +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 => ( +
+
+
{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} + +
+ + + + +
+ + {/* 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]) => ( +
+
+ {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 => ( + + ))} + + + + {result.tradeLog.slice(-10).map((t: any, i: number) => ( + + + + + + + + ))} + +
{h}
{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 */} + + + {/* Condition */} + + + {/* Value */} + onChange(rule.id, { value: parseFloat(e.target.value) || 0 })} + /> + + {/* THEN */} + + + {/* Action */} + + + {/* Quantity */} + onChange(rule.id, { quantity: parseFloat(e.target.value) || 1 })} + /> + + {/* Qty type */} + + + {/* Delete */} + +
+ ); +} + +// ─── 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 && ( + + )} + +
+
+ + {/* Column headers */} +
+ IF Indicator + Condition + Value + + Action + Qty + Type +
+ + {/* Drag-and-drop rule list */} + + r.id)} strategy={verticalListSortingStrategy}> + {rules.map((rule, i) => ( + + ))} + + + + {/* 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} -
- ))} - + {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}
))} -
- {/* 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}
))} - + {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 ( + + ); +} 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)} /> ))}
- {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

+
+ +
{/* Filters */} -
+
{/* Search */} -
- +
+
+ {/* Market cap */} + + {/* Sector pills */} -
- - {SECTORS.map(s => ( +
+ + {SECTORS.slice(0, 6).map(s => ( ))} + {/* Sector dropdown for rest */} +
+ {/* 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'),