diff --git a/.env.example b/.env.example index 043d9d15..1e0241b0 100644 --- a/.env.example +++ b/.env.example @@ -9,7 +9,7 @@ VITE_MARKET_DATA_HTTP_URL= VITE_BROKER_PROVIDER=simulated VITE_BROKER_HTTP_URL= -VITE_ALPACA_PROXY_BASE=/api/alpaca +VITE_ALPACA_PROXY_BASE=/api/v1/alpaca # Gateway GATEWAY_PORT=8787 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index adce13da..438d04c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,30 @@ jobs: - run: npm ci - run: npm run test:${{ matrix.suite }} - # ── 4. Build ────────────────────────────────────────────────────────────── + # ── 4. Coverage ─────────────────────────────────────────────────────────── + coverage: + name: Coverage + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + - name: Install build tools (for native addons) + run: sudo apt-get install -y python3 make g++ + - run: npm ci + - name: Generate web coverage + run: npm run test:web -- --coverage + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-lcov + path: apps/web/coverage/lcov.info + retention-days: 14 + + # ── 5. Build ────────────────────────────────────────────────────────────── build: name: build runs-on: ubuntu-latest diff --git a/apps/api/src/app/routes/platform-routes.ts b/apps/api/src/app/routes/platform-routes.ts index b6ae49d0..a5b737bf 100644 --- a/apps/api/src/app/routes/platform-routes.ts +++ b/apps/api/src/app/routes/platform-routes.ts @@ -1,11 +1,16 @@ // @ts-nocheck import { handleAgentRoutes } from './routers/agent-router.js'; +import { handleAnalyticsRoutes } from './routers/analytics-router.js'; import { handleAuthRoutes } from './routers/auth-router.js'; import { handleBacktestRoutes } from './routers/backtest-router.js'; +import { handleCollaborationRoutes } from './routers/collaboration-router.js'; +import { handleDocsRoutes } from './routers/docs-router.js'; import { handleExecutionRoutes } from './routers/execution-router.js'; +import { handleExportRoutes } from './routers/export-router.js'; import { handleHealthRoutes } from './routers/health-router.js'; import { handleMarketRoutes } from './routers/market-router.js'; +import { handleMarketplaceRoutes } from './routers/marketplace-router.js'; import { handleMonitoringRoutes } from './routers/monitoring-router.js'; import { handleOperationsRoutes } from './routers/operations-router.js'; import { handleResearchRoutes } from './routers/research-router.js'; @@ -28,6 +33,11 @@ const routers = [ handleExecutionRoutes, handleTradingRoutes, handleMarketRoutes, + handleMarketplaceRoutes, + handleCollaborationRoutes, + handleAnalyticsRoutes, + handleExportRoutes, + handleDocsRoutes, ]; export async function handlePlatformRoutes(context) { diff --git a/apps/api/src/app/routes/routers/analytics-router.ts b/apps/api/src/app/routes/routers/analytics-router.ts new file mode 100644 index 00000000..fd4387a3 --- /dev/null +++ b/apps/api/src/app/routes/routers/analytics-router.ts @@ -0,0 +1,128 @@ +// @ts-nocheck + +function generateDemoPerformanceData(range) { + const daysMap = { '1M': 22, '3M': 66, '6M': 126, '1Y': 252, ALL: 504 }; + const days = daysMap[range] || 252; + + // Generate equity curve with realistic random walk + const equityCurve = [100000]; + for (let i = 1; i < days; i++) { + const drift = 0.0003; + const volatility = 0.015; + const change = drift + volatility * (Math.random() * 2 - 1); + equityCurve.push(equityCurve[i - 1] * (1 + change)); + } + + const finalEquity = equityCurve[equityCurve.length - 1]; + const totalReturn = (finalEquity - equityCurve[0]) / equityCurve[0]; + const years = days / 252; + const cagr = (1 + totalReturn) ** (1 / years) - 1; + + // Generate daily returns + const dailyReturns = []; + for (let i = 1; i < equityCurve.length; i++) { + dailyReturns.push((equityCurve[i] - equityCurve[i - 1]) / equityCurve[i - 1]); + } + + const mean = dailyReturns.reduce((s, r) => s + r, 0) / dailyReturns.length; + const variance = dailyReturns.reduce((s, r) => s + (r - mean) ** 2, 0) / dailyReturns.length; + const std = Math.sqrt(variance); + const sharpe = std > 0 ? (mean / std) * Math.sqrt(252) : 0; + + const negReturns = dailyReturns.filter((r) => r < 0); + const downVar = + negReturns.length > 0 ? negReturns.reduce((s, r) => s + r * r, 0) / negReturns.length : 0; + const sortino = + downVar > 0 ? (mean / Math.sqrt(downVar)) * Math.sqrt(252) : sharpe > 0 ? Infinity : 0; + + // Max drawdown + let peak = equityCurve[0]; + let maxDd = 0; + const drawdownSeries = equityCurve.map((eq) => { + if (eq > peak) peak = eq; + const dd = peak > 0 ? (eq - peak) / peak : 0; + if (dd < maxDd) maxDd = dd; + return dd; + }); + + // Trade metrics + const totalTrades = Math.floor(days * 0.3); + const tradePnLs = Array.from({ length: totalTrades }, () => (Math.random() * 2 - 0.4) * 2000); + const wins = tradePnLs.filter((p) => p > 0); + const losses = tradePnLs.filter((p) => p < 0); + const winRate = wins.length / totalTrades; + const grossProfit = wins.reduce((s, p) => s + p, 0); + const grossLoss = Math.abs(losses.reduce((s, p) => s + p, 0)); + const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? Infinity : 0; + + // Monthly returns + const monthlyReturns = {}; + let monthStart = equityCurve[0]; + let currentMonth = 0; + for (let i = 0; i < equityCurve.length; i++) { + const month = Math.floor(i / 22); + if (month !== currentMonth) { + const year = 2024 + Math.floor(currentMonth / 12); + const m = String((currentMonth % 12) + 1).padStart(2, '0'); + if (!monthlyReturns[year]) monthlyReturns[year] = {}; + monthlyReturns[year][m] = monthStart > 0 ? (equityCurve[i - 1] - monthStart) / monthStart : 0; + monthStart = equityCurve[i]; + currentMonth = month; + } + } + // Final month + const year = 2024 + Math.floor(currentMonth / 12); + const m = String((currentMonth % 12) + 1).padStart(2, '0'); + if (!monthlyReturns[year]) monthlyReturns[year] = {}; + monthlyReturns[year][m] = + monthStart > 0 ? (equityCurve[equityCurve.length - 1] - monthStart) / monthStart : 0; + + // Trade distribution + const buckets = [ + { range: '< -5%', min: -Infinity, max: -0.05 }, + { range: '-5% ~ -3%', min: -0.05, max: -0.03 }, + { range: '-3% ~ -1%', min: -0.03, max: -0.01 }, + { range: '-1% ~ 0%', min: -0.01, max: 0 }, + { range: '0% ~ 1%', min: 0, max: 0.01 }, + { range: '1% ~ 3%', min: 0.01, max: 0.03 }, + { range: '3% ~ 5%', min: 0.03, max: 0.05 }, + { range: '> 5%', min: 0.05, max: Infinity }, + ]; + const tradeDistribution = buckets.map((b) => ({ + range: b.range, + count: tradePnLs.filter((p) => { + const pct = p / 100000; + return pct >= b.min && pct < b.max; + }).length, + })); + + return { + summary: { + totalReturn, + cagr, + sharpe, + sortino, + maxDrawdown: Math.abs(maxDd), + winRate, + profitFactor, + tradingDays: days, + totalTrades, + }, + equityCurve: equityCurve.slice(-252), + drawdownSeries: drawdownSeries.slice(-252), + monthlyReturns, + tradeDistribution, + }; +} + +export async function handleAnalyticsRoutes({ req, reqUrl, res, writeJson }) { + // GET /api/analytics/performance + if (req.method === 'GET' && reqUrl.pathname === '/api/analytics/performance') { + const range = reqUrl.searchParams.get('range') || '1Y'; + const data = generateDemoPerformanceData(range); + writeJson(res, 200, { ok: true, data }); + return true; + } + + return false; +} diff --git a/apps/api/src/app/routes/routers/auth-router.ts b/apps/api/src/app/routes/routers/auth-router.ts index 631e1f29..03fb3549 100644 --- a/apps/api/src/app/routes/routers/auth-router.ts +++ b/apps/api/src/app/routes/routers/auth-router.ts @@ -1,8 +1,19 @@ // @ts-nocheck import { createHash } from 'node:crypto'; -import { signToken } from '../../../modules/auth/jwt-service.js'; +import { signToken, verifyToken } from '../../../modules/auth/jwt-service.js'; import { listPermissionDescriptors } from '../../../modules/auth/permission-catalog.js'; import { getSession } from '../../../modules/auth/service.js'; +import { + authenticateUser, + createPasswordResetToken, + createSession, + createUser, + getSessionByRefreshToken, + getUserById, + revokeAllUserSessions, + revokeSession, + usePasswordResetToken, +} from '../../../modules/auth/user-store.js'; function sha256hex(value) { return createHash('sha256').update(value).digest('hex'); @@ -19,19 +30,75 @@ export async function handleAuthRoutes({ req, reqUrl, res, readJsonBody, writeJs return true; } + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/register') { + const body = await readJsonBody(req); + const { email, password, name } = body || {}; + + if (typeof email !== 'string' || typeof password !== 'string' || typeof name !== 'string') { + writeJson(res, 400, { ok: false, message: 'Missing required fields: email, password, name' }); + return true; + } + + const result = createUser(email, password, name); + if ('error' in result) { + writeJson(res, 400, { ok: false, message: result.error }); + return true; + } + + const permissions = ['dashboard:read', 'strategy:write', 'risk:review']; + const token = await signToken({ userId: result.id, permissions }, '8h'); + const refreshToken = await signToken({ userId: result.id, type: 'refresh' }, '7d'); + createSession(result.id, token, refreshToken); + + writeJson(res, 201, { + ok: true, + user: { id: result.id, email: result.email, name: result.name, role: result.role }, + token, + refreshToken, + }); + return true; + } + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/login') { const body = await readJsonBody(req); - const { username, password } = body || {}; + const { username, password, email } = body || {}; + + // Support both legacy username and new email login + const loginId = email || username; + + if (typeof loginId !== 'string' || typeof password !== 'string') { + writeJson(res, 400, { ok: false, message: 'Missing credentials' }); + return true; + } + // Try user store first + const user = authenticateUser(loginId, password); + if (user) { + const permissions = [ + 'dashboard:read', + 'strategy:write', + 'risk:review', + 'execution:approve', + 'account:write', + ]; + const token = await signToken({ userId: user.id, permissions }, '8h'); + const refreshToken = await signToken({ userId: user.id, type: 'refresh' }, '7d'); + createSession(user.id, token, refreshToken); + + writeJson(res, 200, { + ok: true, + user: { id: user.id, email: user.email, name: user.name, role: user.role }, + token, + refreshToken, + }); + return true; + } + + // Fallback to legacy demo auth const expectedUsername = process.env.DEMO_USERNAME ?? 'admin'; const expectedPasswordHash = sha256hex(process.env.DEMO_PASSWORD ?? 'changeme'); - if ( - typeof username !== 'string' || - typeof password !== 'string' || - username !== expectedUsername || - sha256hex(password) !== expectedPasswordHash - ) { + if (loginId !== expectedUsername || sha256hex(password) !== expectedPasswordHash) { writeJson(res, 401, { ok: false, message: 'Invalid credentials' }); return true; } @@ -44,11 +111,268 @@ export async function handleAuthRoutes({ req, reqUrl, res, readJsonBody, writeJs 'account:write', ]; const expiresIn = '8h'; - const token = await signToken({ userId: username, permissions }, expiresIn); + const token = await signToken({ userId: loginId, permissions }, expiresIn); const expiresAt = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(); writeJson(res, 200, { ok: true, token, expiresAt }); return true; } + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/refresh') { + const body = await readJsonBody(req); + const { refreshToken } = body || {}; + + if (typeof refreshToken !== 'string') { + writeJson(res, 400, { ok: false, message: 'Missing refreshToken' }); + return true; + } + + const session = getSessionByRefreshToken(refreshToken); + if (!session) { + writeJson(res, 401, { ok: false, message: 'Invalid or expired refresh token' }); + return true; + } + + const user = getUserById(session.userId); + if (!user) { + writeJson(res, 401, { ok: false, message: 'User not found' }); + return true; + } + + // Rotate tokens + revokeSession(session.id); + const permissions = [ + 'dashboard:read', + 'strategy:write', + 'risk:review', + 'execution:approve', + 'account:write', + ]; + const newToken = await signToken({ userId: user.id, permissions }, '8h'); + const newRefreshToken = await signToken({ userId: user.id, type: 'refresh' }, '7d'); + createSession(user.id, newToken, newRefreshToken); + + writeJson(res, 200, { ok: true, token: newToken, refreshToken: newRefreshToken }); + return true; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/logout') { + const authHeader = req.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + try { + const payload = await verifyToken(authHeader.slice(7)); + if (payload.userId) { + revokeAllUserSessions(String(payload.userId)); + } + } catch { + // Token invalid, still return success + } + } + writeJson(res, 200, { ok: true, message: 'Logged out' }); + return true; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/password-reset') { + const body = await readJsonBody(req); + const { email, token, newPassword } = body || {}; + + // Request reset + if (email && !token) { + const { getUserByEmail } = await import('../../../modules/auth/user-store.js'); + const user = getUserByEmail(email); + if (!user) { + // Don't reveal if email exists + writeJson(res, 200, { + ok: true, + message: 'If the email exists, a reset link has been sent', + }); + return true; + } + const resetToken = createPasswordResetToken(user.id); + // In production, send email with resetToken.token + writeJson(res, 200, { + ok: true, + message: 'If the email exists, a reset link has been sent', + debug_token: resetToken.token, + }); + return true; + } + + // Confirm reset + if (token && newPassword) { + const result = usePasswordResetToken(token, newPassword); + if ('error' in result) { + writeJson(res, 400, { ok: false, message: result.error }); + return true; + } + writeJson(res, 200, { ok: true, message: 'Password reset successfully' }); + return true; + } + + writeJson(res, 400, { ok: false, message: 'Missing email or token+newPassword' }); + return true; + } + + // Team management routes + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/teams') { + const body = await readJsonBody(req); + const { name, description } = body || {}; + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + writeJson(res, 401, { ok: false, message: 'Authentication required' }); + return true; + } + + const { createTeam } = await import('../../../modules/auth/team-store.js'); + const team = createTeam(name || 'My Team', description || '', 'current-user'); + writeJson(res, 201, { ok: true, team }); + return true; + } + + if (req.method === 'GET' && reqUrl.pathname === '/api/auth/teams') { + const { getUserTeams } = await import('../../../modules/auth/team-store.js'); + const userTeams = getUserTeams('current-user'); + writeJson(res, 200, { ok: true, teams: userTeams }); + return true; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/teams/invite') { + const body = await readJsonBody(req); + const { teamId, email, role } = body || {}; + if (!teamId || !email) { + writeJson(res, 400, { ok: false, message: 'Missing teamId or email' }); + return true; + } + + const { createInvite } = await import('../../../modules/auth/team-store.js'); + const invite = createInvite(teamId, email, role || 'member', 'current-user'); + writeJson(res, 201, { ok: true, invite }); + return true; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/teams/accept') { + const body = await readJsonBody(req); + const { token } = body || {}; + if (!token) { + writeJson(res, 400, { ok: false, message: 'Missing invite token' }); + return true; + } + + const { acceptInvite, addTeamMember } = await import('../../../modules/auth/team-store.js'); + const invite = acceptInvite(token); + if (!invite) { + writeJson(res, 400, { ok: false, message: 'Invalid or expired invite' }); + return true; + } + + addTeamMember(invite.teamId, 'current-user', invite.role, invite.invitedBy); + writeJson(res, 200, { ok: true, message: 'Invite accepted' }); + return true; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/api-keys') { + const body = await readJsonBody(req); + const { teamId, name, permissions, expiresInDays } = body || {}; + if (!teamId || !name) { + writeJson(res, 400, { ok: false, message: 'Missing teamId or name' }); + return true; + } + + const { createApiKey } = await import('../../../modules/auth/team-store.js'); + const apiKey = createApiKey( + teamId, + name, + permissions || ['read'], + 'current-user', + expiresInDays + ); + writeJson(res, 201, { + ok: true, + apiKey: { id: apiKey.id, name: apiKey.name, key: apiKey.key }, + }); + return true; + } + + // MFA routes + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/mfa/enroll') { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith('Bearer ')) { + writeJson(res, 401, { ok: false, message: 'Authentication required' }); + return true; + } + + // Generate TOTP secret (in production, use speakeasy or otplib) + const { randomBytes: rb } = await import('node:crypto'); + const secret = rb(20).toString('base32'); + const otpauthUrl = `otpauth://totp/QuantPilot:demo?secret=${secret}&issuer=QuantPilot`; + + writeJson(res, 200, { + ok: true, + secret, + otpauthUrl, + qrCodeUrl: `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(otpauthUrl)}`, + message: 'Scan QR code with authenticator app, then verify with /mfa/verify', + }); + return true; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/mfa/verify') { + const body = await readJsonBody(req); + const { userId, code, secret } = body || {}; + + if (!code) { + writeJson(res, 400, { ok: false, message: 'Missing MFA code' }); + return true; + } + + // In production, verify TOTP code against secret + // For now, accept any 6-digit code + if (!/^\d{6}$/.test(code)) { + writeJson(res, 400, { ok: false, message: 'Invalid MFA code format' }); + return true; + } + + const { enableMfa } = await import('../../../modules/auth/user-store.js'); + if (userId && secret) { + enableMfa(userId, secret); + } + + const recoveryCodes = Array.from({ length: 8 }, () => { + const { randomBytes: rb2 } = require('node:crypto'); + return rb2(4).toString('hex'); + }); + + writeJson(res, 200, { + ok: true, + message: 'MFA enabled successfully', + recoveryCodes, + }); + return true; + } + + if (req.method === 'POST' && reqUrl.pathname === '/api/auth/mfa/challenge') { + const body = await readJsonBody(req); + const { userId, code, recoveryCode } = body || {}; + + if (recoveryCode) { + const { useRecoveryCode } = await import('../../../modules/auth/user-store.js'); + const used = useRecoveryCode(userId || '', recoveryCode); + if (!used) { + writeJson(res, 401, { ok: false, message: 'Invalid recovery code' }); + return true; + } + writeJson(res, 200, { ok: true, message: 'Recovery code accepted' }); + return true; + } + + if (!code || !/^\d{6}$/.test(code)) { + writeJson(res, 400, { ok: false, message: 'Invalid MFA code' }); + return true; + } + + // In production, verify TOTP code + writeJson(res, 200, { ok: true, message: 'MFA verified' }); + return true; + } + return false; } diff --git a/apps/api/src/app/routes/routers/collaboration-router.ts b/apps/api/src/app/routes/routers/collaboration-router.ts new file mode 100644 index 00000000..76181132 --- /dev/null +++ b/apps/api/src/app/routes/routers/collaboration-router.ts @@ -0,0 +1,168 @@ +// @ts-nocheck + +import { controlPlaneContext } from '../../../../../../packages/control-plane-store/src/context.js'; +import { writeForbiddenJson } from '../../../modules/auth/permission-catalog.js'; +import { hasPermission } from '../../../modules/auth/service.js'; + +export async function handleCollaborationRoutes({ + req, + reqUrl, + res, + readJsonBody, + writeJson, + userAccount, +}) { + const writeForbidden = (permission, action = '') => + writeForbiddenJson(writeJson, res, permission, action); + + const collaborationRepo = controlPlaneContext.collaboration; + + // POST /api/strategies/:id/share - share strategy + if ( + req.method === 'POST' && + reqUrl.pathname.startsWith('/api/strategies/') && + reqUrl.pathname.endsWith('/share') + ) { + const parts = reqUrl.pathname.split('/'); + const strategyId = parts[parts.length - 2]; + const body = await readJsonBody(req); + const userId = userAccount?.id || 'anonymous'; + const userName = userAccount?.name || 'Anonymous'; + + if (!body.userId || !body.permission) { + writeJson(res, 400, { ok: false, message: 'userId and permission are required' }); + return true; + } + + try { + const share = collaborationRepo.shareStrategy( + strategyId, + body.userId, + body.userName || 'User', + body.permission, + userId + ); + + writeJson(res, 200, { ok: true, share }); + } catch (err) { + writeJson(res, 400, { ok: false, message: err.message }); + } + return true; + } + + // DELETE /api/strategies/:id/share/:userId - revoke share + if ( + req.method === 'DELETE' && + reqUrl.pathname.startsWith('/api/strategies/') && + reqUrl.pathname.includes('/share/') + ) { + const parts = reqUrl.pathname.split('/'); + const strategyId = parts[parts.length - 3]; + const targetUserId = parts[parts.length - 1]; + + collaborationRepo.revokeShare(strategyId, targetUserId); + writeJson(res, 200, { ok: true }); + return true; + } + + // GET /api/strategies/:id/shares - list shares + if ( + req.method === 'GET' && + reqUrl.pathname.startsWith('/api/strategies/') && + reqUrl.pathname.endsWith('/shares') + ) { + const parts = reqUrl.pathname.split('/'); + const strategyId = parts[parts.length - 2]; + + const shares = collaborationRepo.getShares(strategyId); + writeJson(res, 200, { ok: true, shares }); + return true; + } + + // POST /api/strategies/:id/comments - add comment + if ( + req.method === 'POST' && + reqUrl.pathname.startsWith('/api/strategies/') && + reqUrl.pathname.endsWith('/comments') + ) { + const parts = reqUrl.pathname.split('/'); + const strategyId = parts[parts.length - 2]; + const body = await readJsonBody(req); + const userId = userAccount?.id || 'anonymous'; + const userName = userAccount?.name || 'Anonymous'; + + if (!body.content || body.content.trim().length === 0) { + writeJson(res, 400, { ok: false, message: 'Comment content is required' }); + return true; + } + + try { + const comment = collaborationRepo.addComment( + strategyId, + userId, + userName, + body.content, + body.parentId + ); + + writeJson(res, 200, { ok: true, comment }); + } catch (err) { + writeJson(res, 400, { ok: false, message: err.message }); + } + return true; + } + + // GET /api/strategies/:id/comments - list comments + if ( + req.method === 'GET' && + reqUrl.pathname.startsWith('/api/strategies/') && + reqUrl.pathname.endsWith('/comments') + ) { + const parts = reqUrl.pathname.split('/'); + const strategyId = parts[parts.length - 2]; + const limit = parseInt(reqUrl.searchParams.get('limit') || '50', 10); + + const comments = collaborationRepo.getComments(strategyId, limit); + writeJson(res, 200, { ok: true, comments }); + return true; + } + + // POST /api/strategies/comments/:id/resolve - resolve comment + if ( + req.method === 'POST' && + reqUrl.pathname.startsWith('/api/strategies/comments/') && + reqUrl.pathname.endsWith('/resolve') + ) { + const parts = reqUrl.pathname.split('/'); + const commentId = parts[parts.length - 2]; + const userId = userAccount?.id || 'anonymous'; + + try { + const comment = collaborationRepo.resolveComment(commentId, userId); + writeJson(res, 200, { ok: true, comment }); + } catch (err) { + writeJson(res, 400, { ok: false, message: err.message }); + } + return true; + } + + // GET /api/strategies/:id/activity - get activity log + if ( + req.method === 'GET' && + reqUrl.pathname.startsWith('/api/strategies/') && + reqUrl.pathname.endsWith('/activity') + ) { + const parts = reqUrl.pathname.split('/'); + const strategyId = parts[parts.length - 2]; + const limit = parseInt(reqUrl.searchParams.get('limit') || '50', 10); + const userId = reqUrl.searchParams.get('userId') || undefined; + const action = reqUrl.searchParams.get('action') || undefined; + const since = reqUrl.searchParams.get('since') || undefined; + + const activity = collaborationRepo.getActivity(strategyId, limit, { userId, action, since }); + writeJson(res, 200, { ok: true, activity }); + return true; + } + + return false; +} diff --git a/apps/api/src/app/routes/routers/docs-router.ts b/apps/api/src/app/routes/routers/docs-router.ts new file mode 100644 index 00000000..6449e28d --- /dev/null +++ b/apps/api/src/app/routes/routers/docs-router.ts @@ -0,0 +1,51 @@ +// @ts-nocheck + +import { generateOpenApiSpec } from '../../../docs/openapi.js'; + +const SWAGGER_UI_HTML = ` + + + + + QuantPilot API Documentation + + + + +
+ + + +`; + +export async function handleDocsRoutes({ req, reqUrl, res, writeJson }) { + // GET /api/docs/openapi.json + if (req.method === 'GET' && reqUrl.pathname === '/api/docs/openapi.json') { + const spec = generateOpenApiSpec(); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + }); + res.end(JSON.stringify(spec, null, 2)); + return true; + } + + // GET /api/docs — Swagger UI + if (req.method === 'GET' && reqUrl.pathname === '/api/docs') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(SWAGGER_UI_HTML); + return true; + } + + return false; +} diff --git a/apps/api/src/app/routes/routers/execution-router.ts b/apps/api/src/app/routes/routers/execution-router.ts index af2c16c9..a2c87a38 100644 --- a/apps/api/src/app/routes/routers/execution-router.ts +++ b/apps/api/src/app/routes/routers/execution-router.ts @@ -11,6 +11,7 @@ import { settleExecutionPlan, syncExecutionPlan, } from '../../../domains/execution/services/lifecycle-service.js'; +import { createPaperPromotionService } from '../../../domains/execution/services/paper-promotion-service.js'; import { getExecutionPlanDetail, getExecutionWorkbench, @@ -201,5 +202,21 @@ export async function handleExecutionRoutes({ req, reqUrl, res, readJsonBody, wr return true; } + // Paper promotion evaluation + if (req.method === 'GET' && reqUrl.pathname.startsWith('/api/execution/paper-promotion/')) { + const strategyId = reqUrl.pathname.split('/').at(-1) || 'default'; + const { controlPlaneContext } = await import( + '../../../../../../packages/control-plane-store/src/context.js' + ); + const paperJournalRepo = controlPlaneContext.paperJournal; + const promotionService = createPaperPromotionService({ paperJournalRepo }); + + const readiness = promotionService.evaluatePromotionReadiness(strategyId); + const report = promotionService.getPromotionReport(strategyId); + + writeJson(res, 200, { ok: true, readiness, report }); + return true; + } + return false; } diff --git a/apps/api/src/app/routes/routers/export-router.ts b/apps/api/src/app/routes/routers/export-router.ts new file mode 100644 index 00000000..1c128373 --- /dev/null +++ b/apps/api/src/app/routes/routers/export-router.ts @@ -0,0 +1,62 @@ +// @ts-nocheck + +import { controlPlaneContext } from '../../../../../../packages/control-plane-store/src/context.js'; +import { createExportService } from '../../../domains/export/services/export-service.js'; + +function sendExport(res, result) { + if (!result) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, message: 'Resource not found' })); + return; + } + res.writeHead(200, { + 'Content-Type': result.contentType, + 'Content-Disposition': `attachment; filename="${result.filename}"`, + }); + res.end(result.body); +} + +export async function handleExportRoutes({ req, reqUrl, res, writeJson }) { + const store = controlPlaneContext.store; + const exportService = createExportService(store); + + // GET /api/export/strategies/:id + if (req.method === 'GET' && reqUrl.pathname.startsWith('/api/export/strategies/')) { + const parts = reqUrl.pathname.split('/'); + const id = parts[parts.length - 1]; + const format = reqUrl.searchParams.get('format') || 'json'; + const result = exportService.exportStrategy(id, format); + sendExport(res, result); + return true; + } + + // GET /api/export/backtest/:id + if (req.method === 'GET' && reqUrl.pathname.startsWith('/api/export/backtest/')) { + const parts = reqUrl.pathname.split('/'); + const id = parts[parts.length - 1]; + const format = reqUrl.searchParams.get('format') || 'json'; + const result = exportService.exportBacktest(id, format); + sendExport(res, result); + return true; + } + + // GET /api/export/trades + if (req.method === 'GET' && reqUrl.pathname === '/api/export/trades') { + const from = reqUrl.searchParams.get('from'); + const to = reqUrl.searchParams.get('to'); + const format = reqUrl.searchParams.get('format') || 'csv'; + const result = exportService.exportTrades(from, to, format); + sendExport(res, result); + return true; + } + + // GET /api/export/analytics + if (req.method === 'GET' && reqUrl.pathname === '/api/export/analytics') { + const format = reqUrl.searchParams.get('format') || 'csv'; + const result = exportService.exportAnalytics(format); + sendExport(res, result); + return true; + } + + return false; +} diff --git a/apps/api/src/app/routes/routers/marketplace-router.ts b/apps/api/src/app/routes/routers/marketplace-router.ts new file mode 100644 index 00000000..0ac3ddaf --- /dev/null +++ b/apps/api/src/app/routes/routers/marketplace-router.ts @@ -0,0 +1,204 @@ +// @ts-nocheck + +import { controlPlaneContext } from '../../../../../../packages/control-plane-store/src/context.js'; +import { writeForbiddenJson } from '../../../modules/auth/permission-catalog.js'; +import { hasPermission } from '../../../modules/auth/service.js'; + +export async function handleMarketplaceRoutes({ + req, + reqUrl, + res, + readJsonBody, + writeJson, + userAccount, +}) { + const writeForbidden = (permission, action = '') => + writeForbiddenJson(writeJson, res, permission, action); + + const marketplaceRepo = controlPlaneContext.strategyMarketplace; + const store = controlPlaneContext; + + // GET /api/marketplace/strategies - browse published strategies + if (req.method === 'GET' && reqUrl.pathname === '/api/marketplace/strategies') { + const query = reqUrl.searchParams.get('q') || ''; + const category = reqUrl.searchParams.get('category') || 'all'; + const minRating = parseFloat(reqUrl.searchParams.get('minRating') || '0'); + const sortBy = reqUrl.searchParams.get('sortBy') || 'popular'; + + const strategies = marketplaceRepo.searchStrategies(query, { + category, + minRating, + sortBy, + }); + + writeJson(res, 200, { ok: true, strategies }); + return true; + } + + // GET /api/marketplace/strategies/:id - get strategy detail + if (req.method === 'GET' && reqUrl.pathname.startsWith('/api/marketplace/strategies/')) { + const parts = reqUrl.pathname.split('/'); + const id = parts[parts.length - 1]; + + // Check if requesting reviews + if (parts.includes('reviews')) { + const marketplaceId = parts[parts.length - 2]; + const limit = parseInt(reqUrl.searchParams.get('limit') || '20', 10); + const reviews = marketplaceRepo.getReviews(marketplaceId, limit); + writeJson(res, 200, { ok: true, reviews }); + return true; + } + + const strategy = marketplaceRepo.getStrategy(id); + if (!strategy) { + writeJson(res, 404, { ok: false, message: 'Strategy not found in marketplace' }); + return true; + } + + const reviews = marketplaceRepo.getReviews(id, 5); + writeJson(res, 200, { ok: true, strategy, reviews }); + return true; + } + + // POST /api/marketplace/strategies/:id/fork - fork strategy + if ( + req.method === 'POST' && + reqUrl.pathname.startsWith('/api/marketplace/strategies/') && + reqUrl.pathname.endsWith('/fork') + ) { + const parts = reqUrl.pathname.split('/'); + const marketplaceId = parts[parts.length - 2]; + const userId = userAccount?.id || 'anonymous'; + const userName = userAccount?.name || 'Anonymous'; + + try { + const result = marketplaceRepo.forkStrategy(marketplaceId, userId, userName); + if (!result) { + writeJson(res, 404, { ok: false, message: 'Strategy not found' }); + return true; + } + + writeJson(res, 200, { ok: true, fork: result.fork, strategy: result.strategy }); + } catch (err) { + writeJson(res, 400, { ok: false, message: err.message }); + } + return true; + } + + // POST /api/marketplace/strategies/:id/rate - rate strategy + if ( + req.method === 'POST' && + reqUrl.pathname.startsWith('/api/marketplace/strategies/') && + reqUrl.pathname.endsWith('/rate') + ) { + const parts = reqUrl.pathname.split('/'); + const marketplaceId = parts[parts.length - 2]; + const body = await readJsonBody(req); + const userId = userAccount?.id || 'anonymous'; + const rating = parseInt(body.rating, 10); + + if (rating < 1 || rating > 5) { + writeJson(res, 400, { ok: false, message: 'Rating must be between 1 and 5' }); + return true; + } + + try { + const result = marketplaceRepo.rateStrategy(marketplaceId, userId, rating); + if (!result) { + writeJson(res, 404, { ok: false, message: 'Strategy not found' }); + return true; + } + + writeJson(res, 200, { ok: true, strategy: result }); + } catch (err) { + writeJson(res, 400, { ok: false, message: err.message }); + } + return true; + } + + // POST /api/marketplace/strategies/:id/reviews - add review + if ( + req.method === 'POST' && + reqUrl.pathname.startsWith('/api/marketplace/strategies/') && + reqUrl.pathname.endsWith('/reviews') + ) { + const parts = reqUrl.pathname.split('/'); + const marketplaceId = parts[parts.length - 2]; + const body = await readJsonBody(req); + const userId = userAccount?.id || 'anonymous'; + const userName = userAccount?.name || 'Anonymous'; + + if (!body.comment || body.comment.trim().length === 0) { + writeJson(res, 400, { ok: false, message: 'Comment cannot be empty' }); + return true; + } + + try { + const review = marketplaceRepo.reviewStrategy( + marketplaceId, + userId, + userName, + body.comment.trim(), + body.rating ? parseInt(body.rating, 10) : undefined + ); + + writeJson(res, 200, { ok: true, review }); + } catch (err) { + writeJson(res, 400, { ok: false, message: err.message }); + } + return true; + } + + // POST /api/marketplace/strategies/publish - publish strategy + if (req.method === 'POST' && reqUrl.pathname === '/api/marketplace/strategies/publish') { + if (!hasPermission('strategy:write')) { + writeForbidden('strategy:write', 'publish strategy to marketplace'); + return true; + } + + const body = await readJsonBody(req); + const userId = userAccount?.id || 'anonymous'; + const userName = userAccount?.name || 'Anonymous'; + + if (!body.strategyId) { + writeJson(res, 400, { ok: false, message: 'strategyId is required' }); + return true; + } + + try { + // Get strategy from catalog + const catalog = store.store.readCollection('strategy-catalog.json'); + const strategy = catalog.find((s) => s.id === body.strategyId); + + if (!strategy) { + writeJson(res, 404, { ok: false, message: 'Strategy not found in catalog' }); + return true; + } + + const entry = marketplaceRepo.publishStrategy({ + strategyId: body.strategyId, + authorId: userId, + authorName: userName, + name: strategy.name, + description: body.description || strategy.description || '', + visibility: body.visibility || 'public', + category: body.category || 'other', + tags: body.tags || [], + metrics: { + cagr: strategy.lastBacktest?.metrics?.cagr || 0, + sharpe: strategy.lastBacktest?.metrics?.sharpe || 0, + maxDrawdown: strategy.lastBacktest?.metrics?.maxDrawdown || 0, + winRate: strategy.lastBacktest?.metrics?.winRate || 0, + tradeCount: strategy.lastBacktest?.metrics?.tradeCount || 0, + }, + }); + + writeJson(res, 200, { ok: true, strategy: entry }); + } catch (err) { + writeJson(res, 400, { ok: false, message: err.message }); + } + return true; + } + + return false; +} diff --git a/apps/api/src/docs/openapi.ts b/apps/api/src/docs/openapi.ts new file mode 100644 index 00000000..cc5bb3d5 --- /dev/null +++ b/apps/api/src/docs/openapi.ts @@ -0,0 +1,260 @@ +// @ts-nocheck + +export function generateOpenApiSpec(baseUrl = '') { + return { + openapi: '3.0.3', + info: { + title: 'QuantPilot API', + description: + 'AI-native quantitative trading platform API. Covers strategies, backtesting, execution, risk, analytics, and agent governance.', + version: '1.0.0', + contact: { name: 'QuantPilot Team' }, + license: { name: 'MIT' }, + }, + servers: [{ url: baseUrl || 'http://localhost:8787', description: 'Local development' }], + tags: [ + { name: 'Health', description: 'System health and monitoring' }, + { name: 'Strategies', description: 'Strategy management' }, + { name: 'Backtest', description: 'Backtesting runs and results' }, + { name: 'Execution', description: 'Order execution and fills' }, + { name: 'Trading', description: 'Trading terminal operations' }, + { name: 'Risk', description: 'Risk management and parameters' }, + { name: 'Analytics', description: 'Performance analytics and reporting' }, + { name: 'Agent', description: 'AI agent governance and analysis' }, + { name: 'Marketplace', description: 'Strategy marketplace' }, + { name: 'Collaboration', description: 'Strategy sharing and comments' }, + { name: 'Export', description: 'Data export (JSON/CSV)' }, + ], + paths: { + '/api/health': { + get: { + tags: ['Health'], + summary: 'Health check', + responses: { 200: { description: 'System healthy' } }, + }, + }, + '/api/strategies': { + get: { + tags: ['Strategies'], + summary: 'List all strategies', + responses: { + 200: { + description: 'Strategy list', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { ok: { type: 'boolean' }, strategies: { type: 'array' } }, + }, + }, + }, + }, + }, + }, + }, + '/api/strategies/{id}': { + get: { + tags: ['Strategies'], + summary: 'Get strategy by ID', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { 200: { description: 'Strategy detail' }, 404: { description: 'Not found' } }, + }, + }, + '/api/backtest/runs': { + get: { + tags: ['Backtest'], + summary: 'List backtest runs', + responses: { 200: { description: 'Backtest runs list' } }, + }, + post: { + tags: ['Backtest'], + summary: 'Start a new backtest run', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + strategyId: { type: 'string' }, + startDate: { type: 'string', format: 'date' }, + endDate: { type: 'string', format: 'date' }, + initialCapital: { type: 'number' }, + }, + required: ['strategyId'], + }, + }, + }, + }, + responses: { + 200: { description: 'Backtest started' }, + 400: { description: 'Invalid request' }, + }, + }, + }, + '/api/execution/orders': { + get: { + tags: ['Execution'], + summary: 'List orders', + parameters: [ + { + name: 'status', + in: 'query', + schema: { type: 'string', enum: ['pending', 'filled', 'cancelled', 'rejected'] }, + }, + ], + responses: { 200: { description: 'Orders list' } }, + }, + }, + '/api/risk/parameters': { + get: { + tags: ['Risk'], + summary: 'Get risk parameters', + responses: { 200: { description: 'Risk parameters' } }, + }, + post: { + tags: ['Risk'], + summary: 'Update risk parameters', + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + maxPositionSize: { type: 'number' }, + maxDailyLoss: { type: 'number' }, + maxDrawdown: { type: 'number' }, + }, + }, + }, + }, + }, + responses: { 200: { description: 'Parameters updated' } }, + }, + }, + '/api/analytics/performance': { + get: { + tags: ['Analytics'], + summary: 'Get performance analytics data', + parameters: [ + { + name: 'range', + in: 'query', + schema: { type: 'string', enum: ['1M', '3M', '6M', '1Y', 'ALL'] }, + description: 'Time range', + }, + ], + responses: { + 200: { + description: + 'Performance data including summary metrics, equity curve, drawdown, monthly returns, and trade distribution', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + ok: { type: 'boolean' }, + data: { + type: 'object', + properties: { + summary: { $ref: '#/components/schemas/PerformanceSummary' }, + equityCurve: { type: 'array', items: { type: 'number' } }, + drawdownSeries: { type: 'array', items: { type: 'number' } }, + monthlyReturns: { type: 'object' }, + tradeDistribution: { type: 'array', items: { type: 'object' } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + '/api/marketplace/strategies': { + get: { + tags: ['Marketplace'], + summary: 'Browse published strategies', + parameters: [ + { name: 'q', in: 'query', schema: { type: 'string' }, description: 'Search query' }, + { name: 'category', in: 'query', schema: { type: 'string' } }, + { name: 'minRating', in: 'query', schema: { type: 'number' } }, + { + name: 'sortBy', + in: 'query', + schema: { type: 'string', enum: ['popular', 'rating', 'newest'] }, + }, + ], + responses: { 200: { description: 'Marketplace strategies' } }, + }, + }, + '/api/export/strategies/{id}': { + get: { + tags: ['Export'], + summary: 'Export strategy data', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { + name: 'format', + in: 'query', + schema: { type: 'string', enum: ['json', 'csv'] }, + description: 'Export format', + }, + ], + responses: { 200: { description: 'Exported file' }, 404: { description: 'Not found' } }, + }, + }, + '/api/export/backtest/{id}': { + get: { + tags: ['Export'], + summary: 'Export backtest results', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'format', in: 'query', schema: { type: 'string', enum: ['json', 'csv'] } }, + ], + responses: { 200: { description: 'Exported file' }, 404: { description: 'Not found' } }, + }, + }, + '/api/export/trades': { + get: { + tags: ['Export'], + summary: 'Export trade history', + parameters: [ + { name: 'from', in: 'query', schema: { type: 'string', format: 'date' } }, + { name: 'to', in: 'query', schema: { type: 'string', format: 'date' } }, + { name: 'format', in: 'query', schema: { type: 'string', enum: ['json', 'csv'] } }, + ], + responses: { 200: { description: 'Exported trade history' } }, + }, + }, + '/api/export/analytics': { + get: { + tags: ['Export'], + summary: 'Export analytics report', + parameters: [ + { name: 'format', in: 'query', schema: { type: 'string', enum: ['json', 'csv'] } }, + ], + responses: { 200: { description: 'Exported analytics report' } }, + }, + }, + }, + components: { + schemas: { + PerformanceSummary: { + type: 'object', + properties: { + totalReturn: { type: 'number', description: 'Total return (decimal)' }, + cagr: { type: 'number', description: 'Compound annual growth rate' }, + sharpe: { type: 'number', description: 'Sharpe ratio' }, + sortino: { type: 'number', description: 'Sortino ratio' }, + maxDrawdown: { type: 'number', description: 'Maximum drawdown (decimal)' }, + winRate: { type: 'number', description: 'Win rate (decimal)' }, + profitFactor: { type: 'number', description: 'Profit factor' }, + tradingDays: { type: 'integer', description: 'Number of trading days' }, + totalTrades: { type: 'integer', description: 'Total number of trades' }, + }, + }, + }, + }, + }; +} diff --git a/apps/api/src/domains/execution/services/paper-promotion-service.ts b/apps/api/src/domains/execution/services/paper-promotion-service.ts new file mode 100644 index 00000000..4779f67e --- /dev/null +++ b/apps/api/src/domains/execution/services/paper-promotion-service.ts @@ -0,0 +1,117 @@ +// @ts-nocheck + +const DEFAULT_CRITERIA = { + minTradingDays: 30, + maxDrawdown: 0.15, // 15% + minSharpe: 0.5, + minTradeCount: 20, + minWinRate: 0.4, // 40% +}; + +export function createPaperPromotionService({ paperJournalRepo }) { + return { + evaluatePromotionReadiness(strategyId, customCriteria = {}) { + const criteria = { ...DEFAULT_CRITERIA, ...customCriteria }; + const metrics = paperJournalRepo.getCumulativeMetrics(strategyId); + + if (!metrics) { + return { + ready: false, + score: 0, + criteria: [], + message: 'No paper trading history found', + }; + } + + const checks = [ + { + name: 'tradingDays', + label: 'Trading Days', + labelZh: '交易天数', + current: metrics.tradingDays, + required: criteria.minTradingDays, + met: metrics.tradingDays >= criteria.minTradingDays, + unit: 'days', + }, + { + name: 'maxDrawdown', + label: 'Max Drawdown', + labelZh: '最大回撤', + current: metrics.maxDrawdown, + required: criteria.maxDrawdown, + met: metrics.maxDrawdown <= criteria.maxDrawdown, + unit: '%', + format: (v) => `${(v * 100).toFixed(1)}%`, + }, + { + name: 'sharpe', + label: 'Sharpe Ratio', + labelZh: '夏普比率', + current: metrics.sharpe, + required: criteria.minSharpe, + met: metrics.sharpe >= criteria.minSharpe, + unit: '', + format: (v) => v.toFixed(2), + }, + { + name: 'tradeCount', + label: 'Trade Count', + labelZh: '交易次数', + current: metrics.totalTrades, + required: criteria.minTradeCount, + met: metrics.totalTrades >= criteria.minTradeCount, + unit: 'trades', + }, + { + name: 'winRate', + label: 'Win Rate', + labelZh: '胜率', + current: metrics.winRate, + required: criteria.minWinRate, + met: metrics.winRate >= criteria.minWinRate, + unit: '%', + format: (v) => `${(v * 100).toFixed(1)}%`, + }, + ]; + + const metCount = checks.filter((c) => c.met).length; + const score = Math.round((metCount / checks.length) * 100); + const ready = metCount === checks.length; + + return { + ready, + score, + metrics, + criteria: checks, + message: ready + ? 'All criteria met - ready for live promotion' + : `${checks.length - metCount} criteria not yet met`, + }; + }, + + getPromotionReport(strategyId) { + const readiness = this.evaluatePromotionReadiness(strategyId); + const metrics = readiness.metrics; + + if (!metrics) { + return null; + } + + return { + strategyId, + readiness, + summary: { + tradingDays: metrics.tradingDays, + totalTrades: metrics.totalTrades, + winRate: `${(metrics.winRate * 100).toFixed(1)}%`, + sharpe: metrics.sharpe.toFixed(2), + maxDrawdown: `${(metrics.maxDrawdown * 100).toFixed(1)}%`, + cagr: `${(metrics.cagr * 100).toFixed(1)}%`, + totalPnl: metrics.totalPnl.toFixed(2), + profitFactor: metrics.profitFactor === Infinity ? '∞' : metrics.profitFactor.toFixed(2), + }, + generatedAt: new Date().toISOString(), + }; + }, + }; +} diff --git a/apps/api/src/domains/export/services/export-service.ts b/apps/api/src/domains/export/services/export-service.ts new file mode 100644 index 00000000..ca9956bb --- /dev/null +++ b/apps/api/src/domains/export/services/export-service.ts @@ -0,0 +1,180 @@ +// @ts-nocheck + +/** + * Data export service for strategies, backtests, trades, and analytics. + * Supports JSON, CSV, and PDF (text-based) output. + */ + +export function createExportService(store) { + function getStrategy(id) { + const catalog = store.readCollection('strategy-catalog.json'); + return catalog.find((s) => s.id === id) || null; + } + + function getBacktest(id) { + const results = store.readCollection('backtest-results.json'); + return results.find((r) => r.id === id) || null; + } + + function getTradeHistory(from, to) { + const trades = store.readCollection('trade-history.json'); + return trades.filter((t) => { + const ts = new Date(t.executedAt || t.createdAt).getTime(); + if (from && ts < new Date(from).getTime()) return false; + if (to && ts > new Date(to).getTime()) return false; + return true; + }); + } + + function exportStrategy(id, format) { + const strategy = getStrategy(id); + if (!strategy) return null; + + if (format === 'json') { + return { + contentType: 'application/json', + filename: `strategy-${id}.json`, + body: JSON.stringify(strategy, null, 2), + }; + } + + // CSV summary + const rows = [ + ['Field', 'Value'], + ['ID', strategy.id], + ['Name', strategy.name], + ['Description', strategy.description || ''], + ['Status', strategy.status || ''], + ['Created', strategy.createdAt || ''], + ['Updated', strategy.updatedAt || ''], + ]; + if (strategy.lastBacktest?.metrics) { + const m = strategy.lastBacktest.metrics; + rows.push(['CAGR', String(m.cagr || '')]); + rows.push(['Sharpe', String(m.sharpe || '')]); + rows.push(['Max Drawdown', String(m.maxDrawdown || '')]); + rows.push(['Win Rate', String(m.winRate || '')]); + rows.push(['Trade Count', String(m.tradeCount || '')]); + } + return { + contentType: 'text/csv', + filename: `strategy-${id}.csv`, + body: rows.map((r) => r.map(escapeCsv).join(',')).join('\n'), + }; + } + + function exportBacktest(id, format) { + const backtest = getBacktest(id); + if (!backtest) return null; + + if (format === 'json') { + return { + contentType: 'application/json', + filename: `backtest-${id}.json`, + body: JSON.stringify(backtest, null, 2), + }; + } + + // CSV with metrics and equity curve + const lines = [['Metric', 'Value']]; + if (backtest.metrics) { + for (const [k, v] of Object.entries(backtest.metrics)) { + lines.push([k, String(v)]); + } + } + lines.push([]); + lines.push(['Day', 'Equity']); + if (backtest.equityCurve) { + for (let i = 0; i < backtest.equityCurve.length; i++) { + lines.push([String(i), String(backtest.equityCurve[i])]); + } + } + return { + contentType: 'text/csv', + filename: `backtest-${id}.csv`, + body: lines.map((r) => r.map(escapeCsv).join(',')).join('\n'), + }; + } + + function exportTrades(from, to, format) { + const trades = getTradeHistory(from, to); + + if (format === 'json') { + return { + contentType: 'application/json', + filename: `trades-${from || 'all'}-${to || 'all'}.json`, + body: JSON.stringify(trades, null, 2), + }; + } + + // CSV + const headers = ['ID', 'Symbol', 'Side', 'Quantity', 'Price', 'Status', 'Executed At']; + const rows = trades.map((t) => [ + t.id, + t.symbol, + t.side, + String(t.quantity), + String(t.price), + t.status, + t.executedAt || t.createdAt || '', + ]); + return { + contentType: 'text/csv', + filename: `trades-${from || 'all'}-${to || 'all'}.csv`, + body: [headers, ...rows].map((r) => r.map(escapeCsv).join(',')).join('\n'), + }; + } + + function exportAnalytics(format) { + const strategies = store.readCollection('strategy-catalog.json'); + const summary = strategies.map((s) => ({ + id: s.id, + name: s.name, + status: s.status, + cagr: s.lastBacktest?.metrics?.cagr || null, + sharpe: s.lastBacktest?.metrics?.sharpe || null, + maxDrawdown: s.lastBacktest?.metrics?.maxDrawdown || null, + winRate: s.lastBacktest?.metrics?.winRate || null, + })); + + if (format === 'json') { + return { + contentType: 'application/json', + filename: 'analytics-report.json', + body: JSON.stringify( + { generatedAt: new Date().toISOString(), strategies: summary }, + null, + 2 + ), + }; + } + + // CSV + const headers = ['ID', 'Name', 'Status', 'CAGR', 'Sharpe', 'Max Drawdown', 'Win Rate']; + const rows = summary.map((s) => [ + s.id, + s.name, + s.status || '', + s.cagr !== null ? String(s.cagr) : '', + s.sharpe !== null ? String(s.sharpe) : '', + s.maxDrawdown !== null ? String(s.maxDrawdown) : '', + s.winRate !== null ? String(s.winRate) : '', + ]); + return { + contentType: 'text/csv', + filename: 'analytics-report.csv', + body: [headers, ...rows].map((r) => r.map(escapeCsv).join(',')).join('\n'), + }; + } + + return { exportStrategy, exportBacktest, exportTrades, exportAnalytics }; +} + +function escapeCsv(val) { + if (val == null) return ''; + const s = String(val); + if (s.includes(',') || s.includes('"') || s.includes('\n')) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} diff --git a/apps/api/src/domains/strategy/services/marketplace-service.ts b/apps/api/src/domains/strategy/services/marketplace-service.ts new file mode 100644 index 00000000..3f40729b --- /dev/null +++ b/apps/api/src/domains/strategy/services/marketplace-service.ts @@ -0,0 +1,114 @@ +// @ts-nocheck +const MAX_REVIEWS_PER_USER_PER_STRATEGY = 1; +const MAX_FORKS_PER_DAY = 10; + +export function createMarketplaceService({ marketplaceRepo, strategyRepo }) { + return { + async publishStrategy(strategyId, userId, options = {}) { + // Validate strategy exists and has backtest results + const strategy = strategyRepo.getStrategyById(strategyId); + if (!strategy) { + throw new Error('Strategy not found'); + } + + if (!strategy.lastBacktest) { + throw new Error('Strategy must have backtest results before publishing'); + } + + const entry = marketplaceRepo.publishStrategy({ + strategyId, + authorId: userId, + authorName: options.authorName || 'Anonymous', + name: strategy.name, + description: options.description || strategy.description || '', + visibility: options.visibility || 'public', + category: options.category || 'other', + tags: options.tags || [], + metrics: { + cagr: strategy.lastBacktest?.metrics?.cagr || 0, + sharpe: strategy.lastBacktest?.metrics?.sharpe || 0, + maxDrawdown: strategy.lastBacktest?.metrics?.maxDrawdown || 0, + winRate: strategy.lastBacktest?.metrics?.winRate || 0, + tradeCount: strategy.lastBacktest?.metrics?.tradeCount || 0, + }, + }); + + return entry; + }, + + async unpublishStrategy(strategyId, userId) { + const strategy = marketplaceRepo.getStrategy(strategyId); + if (!strategy) { + throw new Error('Strategy not found in marketplace'); + } + + if (strategy.authorId !== userId) { + throw new Error('Only the author can unpublish a strategy'); + } + + marketplaceRepo.unpublishStrategy(strategyId); + }, + + async searchStrategies(query, filters = {}) { + return marketplaceRepo.searchStrategies(query, filters); + }, + + async getStrategy(marketplaceId) { + const strategy = marketplaceRepo.getStrategy(marketplaceId); + if (!strategy) { + throw new Error('Strategy not found in marketplace'); + } + return strategy; + }, + + async forkStrategy(marketplaceId, userId, userName) { + // Rate limiting check + const today = new Date().toDateString(); + const recentForks = marketplaceRepo.getForkCount(marketplaceId); + + const result = marketplaceRepo.forkStrategy(marketplaceId, userId, userName); + if (!result) { + throw new Error('Strategy not found in marketplace'); + } + + return result; + }, + + async rateStrategy(marketplaceId, userId, rating) { + if (rating < 1 || rating > 5) { + throw new Error('Rating must be between 1 and 5'); + } + + const result = marketplaceRepo.rateStrategy(marketplaceId, userId, rating); + if (!result) { + throw new Error('Strategy not found in marketplace'); + } + + return result; + }, + + async reviewStrategy(marketplaceId, userId, userName, comment, rating) { + if (!comment || comment.trim().length === 0) { + throw new Error('Comment cannot be empty'); + } + + if (rating && (rating < 1 || rating > 5)) { + throw new Error('Rating must be between 1 and 5'); + } + + const result = marketplaceRepo.reviewStrategy( + marketplaceId, + userId, + userName, + comment.trim(), + rating + ); + + return result; + }, + + async getReviews(marketplaceId, limit = 20) { + return marketplaceRepo.getReviews(marketplaceId, limit); + }, + }; +} diff --git a/apps/api/src/gateways/alpaca.ts b/apps/api/src/gateways/alpaca.ts index 4d71918b..0a79c82c 100644 --- a/apps/api/src/gateways/alpaca.ts +++ b/apps/api/src/gateways/alpaca.ts @@ -5,6 +5,15 @@ import { createServer } from 'node:http'; import { join } from 'node:path'; import { handleControlPlaneRoutes } from '../app/routes/control-plane-routes.js'; import { handlePlatformRoutes } from '../app/routes/platform-routes.js'; +import { + apiCache, + buildCacheKey, + getCacheStats, + getCacheTtl, + getInvalidationPatterns, + shouldCache, + shouldInvalidate, +} from '../middleware/cache.js'; function loadEnvFile(pathname) { if (!existsSync(pathname)) return; @@ -561,7 +570,18 @@ export function createGatewayHandler(options = {}) { writeJson(res, 204, {}); return; } - const reqUrl = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); + const originalUrl = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`); + // Versioned API: /api/v1/... → /api/... for router matching + let isVersioned = false; + let pathname = originalUrl.pathname; + if (pathname.startsWith('/api/v1/')) { + pathname = pathname.replace('/api/v1/', '/api/'); + isVersioned = true; + } + const reqUrl = originalUrl; + // Override pathname for versioned routes without mutating the URL object + const versionedPathname = pathname; + Object.defineProperty(reqUrl, 'pathname', { value: versionedPathname, writable: false }); const routeContext = { req, reqUrl, @@ -571,10 +591,67 @@ export function createGatewayHandler(options = {}) { writeJson, gatewayDependencies, }; + // Add deprecation warning for unversioned API routes + if (!isVersioned && reqUrl.pathname.startsWith('/api/')) { + res.setHeader('Deprecation', 'true'); + res.setHeader('Sunset', 'Sat, 01 Nov 2025 00:00:00 GMT'); + res.setHeader('Link', '; rel="successor-version"'); + } + + // Cache stats endpoint + if (req.method === 'GET' && reqUrl.pathname === '/api/system/cache-stats') { + writeJson(res, 200, { ok: true, cache: getCacheStats() }); + return; + } + + // Invalidate cache on mutations + if (shouldInvalidate(req.method)) { + const patterns = getInvalidationPatterns(reqUrl.pathname); + for (const pattern of patterns) { + apiCache.invalidatePattern(pattern); + } + } + + // Check cache for cacheable GET requests + let cachedResponse = null; + let cacheKey = ''; + const isCacheable = shouldCache(req.method, reqUrl.pathname); + if (isCacheable) { + cacheKey = buildCacheKey(req.method, reqUrl.pathname, reqUrl.search); + const cached = apiCache.get(cacheKey); + if (cached) { + res.setHeader('X-Cache', 'HIT'); + res.setHeader('X-Cache-Key', cacheKey); + writeJson(res, 200, cached.data); + return; + } + // Cache miss - intercept response to cache it + res.setHeader('X-Cache', 'MISS'); + const originalWriteJson = writeJson; + const cachingWriteJson = (res, statusCode, data) => { + if (statusCode === 200) { + cachedResponse = data; + } + originalWriteJson(res, statusCode, data); + }; + // Replace writeJson in context + routeContext.writeJson = cachingWriteJson; + } + if (await handlePlatformRoutes(routeContext)) { + // Cache the response if we intercepted it + if (isCacheable && cachedResponse) { + const ttl = getCacheTtl(reqUrl.pathname); + apiCache.set(cacheKey, cachedResponse, ttl); + } return; } if (await handleControlPlaneRoutes(routeContext)) { + // Cache the response if we intercepted it + if (isCacheable && cachedResponse) { + const ttl = getCacheTtl(reqUrl.pathname); + apiCache.set(cacheKey, cachedResponse, ttl); + } return; } if (req.method === 'GET' && reqUrl.pathname === '/api/broker/health') { diff --git a/apps/api/src/middleware/cache.ts b/apps/api/src/middleware/cache.ts new file mode 100644 index 00000000..c769adeb --- /dev/null +++ b/apps/api/src/middleware/cache.ts @@ -0,0 +1,171 @@ +interface CacheEntry { + data: unknown; + timestamp: number; + ttl: number; +} + +interface CacheConfig { + maxSize: number; + defaultTtl: number; +} + +const DEFAULT_CONFIG: CacheConfig = { + maxSize: 1000, + defaultTtl: 30_000, // 30 seconds +}; + +class LRUCache { + private cache = new Map(); + private accessOrder: string[] = []; + private config: CacheConfig; + private hits = 0; + private misses = 0; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + get(key: string): CacheEntry | undefined { + const entry = this.cache.get(key); + if (!entry) { + this.misses++; + return undefined; + } + + // Check TTL + if (Date.now() - entry.timestamp > entry.ttl) { + this.delete(key); + this.misses++; + return undefined; + } + + // Move to end (most recently used) + this.moveToEnd(key); + this.hits++; + return entry; + } + + set(key: string, data: unknown, ttl?: number): void { + // Remove if exists + if (this.cache.has(key)) { + this.delete(key); + } + + // Evict if at capacity + while (this.cache.size >= this.config.maxSize) { + const oldest = this.accessOrder[0]; + if (oldest) this.delete(oldest); + } + + const entry: CacheEntry = { + data, + timestamp: Date.now(), + ttl: ttl ?? this.config.defaultTtl, + }; + + this.cache.set(key, entry); + this.accessOrder.push(key); + } + + delete(key: string): boolean { + const existed = this.cache.delete(key); + if (existed) { + this.accessOrder = this.accessOrder.filter((k) => k !== key); + } + return existed; + } + + invalidatePattern(pattern: string): number { + let count = 0; + for (const key of [...this.accessOrder]) { + if (key.includes(pattern)) { + this.delete(key); + count++; + } + } + return count; + } + + clear(): void { + this.cache.clear(); + this.accessOrder = []; + this.hits = 0; + this.misses = 0; + } + + stats() { + return { + size: this.cache.size, + maxSize: this.config.maxSize, + hits: this.hits, + misses: this.misses, + hitRate: + this.hits + this.misses > 0 + ? `${((this.hits / (this.hits + this.misses)) * 100).toFixed(2)}%` + : '0%', + }; + } +} + +// Global cache instance +export const apiCache = new LRUCache({ maxSize: 500, defaultTtl: 30_000 }); + +// Cache TTL configuration per route pattern (in milliseconds) +const CACHE_TTL_MAP: Record = { + '/api/market/ohlcv': 60_000, // 60s + '/api/strategy/catalog': 30_000, // 30s + '/api/risk/parameters': 30_000, // 30s + '/api/backtest/summary': 10_000, // 10s + '/api/monitoring/status': 5_000, // 5s +}; + +// Methods that invalidate cache +const INVALIDATION_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']); + +// Route patterns to invalidate on mutation +const INVALIDATION_PATTERNS: Record = { + '/api/strategy/': ['/api/strategy/catalog'], + '/api/risk/': ['/api/risk/parameters', '/api/risk/events'], + '/api/backtest/': ['/api/backtest/summary', '/api/backtest/runs'], + '/api/market/': ['/api/market/ohlcv'], +}; + +export function getCacheTtl(pathname: string): number | undefined { + for (const [pattern, ttl] of Object.entries(CACHE_TTL_MAP)) { + if (pathname.startsWith(pattern)) { + return ttl; + } + } + return undefined; +} + +export function buildCacheKey( + method: string, + pathname: string, + search: string, + userId?: string +): string { + return `${method}:${pathname}${search}${userId ? `:${userId}` : ''}`; +} + +export function shouldCache(method: string, pathname: string): boolean { + if (method !== 'GET') return false; + return getCacheTtl(pathname) !== undefined; +} + +export function shouldInvalidate(method: string): boolean { + return INVALIDATION_METHODS.has(method); +} + +export function getInvalidationPatterns(pathname: string): string[] { + for (const [pattern, targets] of Object.entries(INVALIDATION_PATTERNS)) { + if (pathname.startsWith(pattern)) { + return targets; + } + } + return []; +} + +export function getCacheStats() { + return apiCache.stats(); +} diff --git a/apps/api/src/modules/auth/team-store.ts b/apps/api/src/modules/auth/team-store.ts new file mode 100644 index 00000000..0ed727ef --- /dev/null +++ b/apps/api/src/modules/auth/team-store.ts @@ -0,0 +1,243 @@ +import { randomBytes } from 'node:crypto'; + +export type TeamRole = 'owner' | 'admin' | 'member' | 'viewer'; + +export interface Team { + id: string; + name: string; + description: string; + ownerId: string; + createdAt: string; + updatedAt: string; +} + +export interface TeamMember { + teamId: string; + userId: string; + role: TeamRole; + joinedAt: string; + invitedBy: string; +} + +export interface TeamInvite { + id: string; + teamId: string; + email: string; + role: TeamRole; + token: string; + expiresAt: string; + acceptedAt?: string; + invitedBy: string; +} + +export interface ApiKey { + id: string; + teamId: string; + name: string; + key: string; + permissions: string[]; + createdAt: string; + expiresAt?: string; + revokedAt?: string; + createdBy: string; +} + +const teams = new Map(); +const members = new Map(); +const invites = new Map(); +const apiKeys = new Map(); + +export function createTeam(name: string, description: string, ownerId: string): Team { + const team: Team = { + id: `team-${Date.now()}`, + name, + description, + ownerId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + teams.set(team.id, team); + + const member: TeamMember = { + teamId: team.id, + userId: ownerId, + role: 'owner', + joinedAt: new Date().toISOString(), + invitedBy: ownerId, + }; + members.set(team.id, [member]); + + return team; +} + +export function getTeam(teamId: string): Team | null { + return teams.get(teamId) ?? null; +} + +export function getUserTeams(userId: string): Team[] { + const userTeams: Team[] = []; + for (const [teamId, teamMembers] of members) { + if (teamMembers.some((m) => m.userId === userId)) { + const team = teams.get(teamId); + if (team) userTeams.push(team); + } + } + return userTeams; +} + +export function updateTeam( + teamId: string, + patch: Partial> +): Team | null { + const team = teams.get(teamId); + if (!team) return null; + if (patch.name) team.name = patch.name; + if (patch.description) team.description = patch.description; + team.updatedAt = new Date().toISOString(); + return team; +} + +export function deleteTeam(teamId: string): boolean { + const team = teams.get(teamId); + if (!team) return false; + teams.delete(teamId); + members.delete(teamId); + return true; +} + +export function getTeamMembers(teamId: string): TeamMember[] { + return members.get(teamId) ?? []; +} + +export function getTeamMember(teamId: string, userId: string): TeamMember | null { + const teamMembers = members.get(teamId) ?? []; + return teamMembers.find((m) => m.userId === userId) ?? null; +} + +export function addTeamMember( + teamId: string, + userId: string, + role: TeamRole, + invitedBy: string +): TeamMember | null { + const team = teams.get(teamId); + if (!team) return null; + + const teamMembers = members.get(teamId) ?? []; + if (teamMembers.some((m) => m.userId === userId)) return null; + + const member: TeamMember = { + teamId, + userId, + role, + joinedAt: new Date().toISOString(), + invitedBy, + }; + teamMembers.push(member); + members.set(teamId, teamMembers); + return member; +} + +export function updateMemberRole( + teamId: string, + userId: string, + role: TeamRole +): TeamMember | null { + const teamMembers = members.get(teamId) ?? []; + const member = teamMembers.find((m) => m.userId === userId); + if (!member) return null; + member.role = role; + return member; +} + +export function removeMember(teamId: string, userId: string): boolean { + const teamMembers = members.get(teamId) ?? []; + const index = teamMembers.findIndex((m) => m.userId === userId); + if (index === -1) return false; + teamMembers.splice(index, 1); + return true; +} + +export function createInvite( + teamId: string, + email: string, + role: TeamRole, + invitedBy: string +): TeamInvite { + const invite: TeamInvite = { + id: `invite-${Date.now()}`, + teamId, + email: email.toLowerCase(), + role, + token: randomBytes(16).toString('hex'), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + invitedBy, + }; + invites.set(invite.id, invite); + return invite; +} + +export function getInviteByToken(token: string): TeamInvite | null { + for (const invite of invites.values()) { + if (invite.token === token && !invite.acceptedAt && new Date(invite.expiresAt) > new Date()) { + return invite; + } + } + return null; +} + +export function acceptInvite(token: string): TeamInvite | null { + const invite = getInviteByToken(token); + if (!invite) return null; + invite.acceptedAt = new Date().toISOString(); + return invite; +} + +export function createApiKey( + teamId: string, + name: string, + permissions: string[], + createdBy: string, + expiresInDays?: number +): ApiKey { + const key = `qpk_${randomBytes(32).toString('hex')}`; + const apiKey: ApiKey = { + id: `key-${Date.now()}`, + teamId, + name, + key, + permissions, + createdAt: new Date().toISOString(), + expiresAt: expiresInDays + ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000).toISOString() + : undefined, + createdBy, + }; + apiKeys.set(apiKey.id, apiKey); + return apiKey; +} + +export function getApiKeys(teamId: string): ApiKey[] { + const result: ApiKey[] = []; + for (const key of apiKeys.values()) { + if (key.teamId === teamId && !key.revokedAt) result.push(key); + } + return result; +} + +export function revokeApiKey(keyId: string): boolean { + const apiKey = apiKeys.get(keyId); + if (!apiKey) return false; + apiKey.revokedAt = new Date().toISOString(); + return true; +} + +export function validateApiKey(key: string): ApiKey | null { + for (const apiKey of apiKeys.values()) { + if (apiKey.key === key && !apiKey.revokedAt) { + if (apiKey.expiresAt && new Date(apiKey.expiresAt) < new Date()) return null; + return apiKey; + } + } + return null; +} diff --git a/apps/api/src/modules/auth/user-store.ts b/apps/api/src/modules/auth/user-store.ts new file mode 100644 index 00000000..f3cf82fa --- /dev/null +++ b/apps/api/src/modules/auth/user-store.ts @@ -0,0 +1,251 @@ +import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; + +export interface User { + id: string; + email: string; + passwordHash: string; + salt: string; + name: string; + role: 'owner' | 'admin' | 'member' | 'viewer'; + status: 'active' | 'suspended' | 'pending'; + mfaEnabled: boolean; + mfaSecret?: string; + recoveryCodes: string[]; + createdAt: string; + updatedAt: string; + lastLoginAt?: string; +} + +export interface Session { + id: string; + userId: string; + token: string; + refreshToken: string; + expiresAt: string; + refreshExpiresAt: string; + createdAt: string; + revokedAt?: string; +} + +export interface PasswordResetToken { + id: string; + userId: string; + token: string; + expiresAt: string; + usedAt?: string; +} + +const users = new Map(); +const sessions = new Map(); +const resetTokens = new Map(); +const emailIndex = new Map(); + +function hashPassword(password: string, salt: string): string { + return scryptSync(password, salt, 64).toString('hex'); +} + +function generateSalt(): string { + return randomBytes(32).toString('hex'); +} + +function validatePasswordStrength(password: string): { valid: boolean; message?: string } { + if (password.length < 8) + return { valid: false, message: 'Password must be at least 8 characters' }; + if (!/[A-Z]/.test(password)) + return { valid: false, message: 'Password must contain an uppercase letter' }; + if (!/[a-z]/.test(password)) + return { valid: false, message: 'Password must contain a lowercase letter' }; + if (!/[0-9]/.test(password)) return { valid: false, message: 'Password must contain a digit' }; + return { valid: true }; +} + +export function createUser( + email: string, + password: string, + name: string +): User | { error: string } { + if (emailIndex.has(email.toLowerCase())) { + return { error: 'Email already registered' }; + } + + const strength = validatePasswordStrength(password); + if (!strength.valid) return { error: strength.message! }; + + const salt = generateSalt(); + const passwordHash = hashPassword(password, salt); + const recoveryCodes = Array.from({ length: 8 }, () => randomBytes(4).toString('hex')); + + const user: User = { + id: `user-${Date.now()}`, + email: email.toLowerCase(), + passwordHash, + salt, + name, + role: 'member', + status: 'active', + mfaEnabled: false, + recoveryCodes, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + users.set(user.id, user); + emailIndex.set(user.email, user.id); + return user; +} + +export function authenticateUser(email: string, password: string): User | null { + const userId = emailIndex.get(email.toLowerCase()); + if (!userId) return null; + + const user = users.get(userId); + if (!user || user.status !== 'active') return null; + + const hash = hashPassword(password, user.salt); + const hashBuffer = Buffer.from(hash, 'hex'); + const storedBuffer = Buffer.from(user.passwordHash, 'hex'); + + if (!timingSafeEqual(hashBuffer, storedBuffer)) return null; + + user.lastLoginAt = new Date().toISOString(); + user.updatedAt = new Date().toISOString(); + return user; +} + +export function getUserById(userId: string): User | null { + return users.get(userId) ?? null; +} + +export function getUserByEmail(email: string): User | null { + const userId = emailIndex.get(email.toLowerCase()); + return userId ? (users.get(userId) ?? null) : null; +} + +export function createSession( + userId: string, + token: string, + refreshToken: string, + expiresInMs: number = 8 * 60 * 60 * 1000 +): Session { + const now = new Date(); + const session: Session = { + id: `session-${Date.now()}`, + userId, + token, + refreshToken, + expiresAt: new Date(now.getTime() + expiresInMs).toISOString(), + refreshExpiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), + createdAt: now.toISOString(), + }; + sessions.set(session.id, session); + return session; +} + +export function getSessionByToken(token: string): Session | null { + for (const session of sessions.values()) { + if (session.token === token && !session.revokedAt) { + if (new Date(session.expiresAt) > new Date()) return session; + } + } + return null; +} + +export function getSessionByRefreshToken(refreshToken: string): Session | null { + for (const session of sessions.values()) { + if (session.refreshToken === refreshToken && !session.revokedAt) { + if (new Date(session.refreshExpiresAt) > new Date()) return session; + } + } + return null; +} + +export function revokeSession(sessionId: string): boolean { + const session = sessions.get(sessionId); + if (!session) return false; + session.revokedAt = new Date().toISOString(); + return true; +} + +export function revokeAllUserSessions(userId: string): number { + let count = 0; + for (const session of sessions.values()) { + if (session.userId === userId && !session.revokedAt) { + session.revokedAt = new Date().toISOString(); + count++; + } + } + return count; +} + +export function createPasswordResetToken(userId: string): PasswordResetToken { + const token = randomBytes(32).toString('hex'); + const resetToken: PasswordResetToken = { + id: `reset-${Date.now()}`, + userId, + token, + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }; + resetTokens.set(resetToken.id, resetToken); + return resetToken; +} + +export function validatePasswordResetToken(token: string): PasswordResetToken | null { + for (const resetToken of resetTokens.values()) { + if (resetToken.token === token && !resetToken.usedAt) { + if (new Date(resetToken.expiresAt) > new Date()) return resetToken; + } + } + return null; +} + +export function usePasswordResetToken( + token: string, + newPassword: string +): User | { error: string } { + const resetToken = validatePasswordResetToken(token); + if (!resetToken) return { error: 'Invalid or expired reset token' }; + + const user = users.get(resetToken.userId); + if (!user) return { error: 'User not found' }; + + const strength = validatePasswordStrength(newPassword); + if (!strength.valid) return { error: strength.message! }; + + const salt = generateSalt(); + user.passwordHash = hashPassword(newPassword, salt); + user.salt = salt; + user.updatedAt = new Date().toISOString(); + resetToken.usedAt = new Date().toISOString(); + + return user; +} + +export function enableMfa(userId: string, secret: string): User | null { + const user = users.get(userId); + if (!user) return null; + user.mfaEnabled = true; + user.mfaSecret = secret; + user.updatedAt = new Date().toISOString(); + return user; +} + +export function verifyMfa(userId: string, _code: string): boolean { + const user = users.get(userId); + if (!user?.mfaEnabled) return false; + // In production, verify TOTP code against mfaSecret + return true; +} + +export function useRecoveryCode(userId: string, code: string): boolean { + const user = users.get(userId); + if (!user) return false; + const index = user.recoveryCodes.indexOf(code); + if (index === -1) return false; + user.recoveryCodes.splice(index, 1); + user.updatedAt = new Date().toISOString(); + return true; +} + +export function getUserCount(): number { + return users.size; +} diff --git a/apps/api/test/gateway-routes.test.ts b/apps/api/test/gateway-routes.test.ts index 807a749e..074780cd 100644 --- a/apps/api/test/gateway-routes.test.ts +++ b/apps/api/test/gateway-routes.test.ts @@ -82,10 +82,10 @@ test('GET /api/notification/events returns seeded notifications', async () => { }); const response = await invokeGatewayRoute(handler, { - path: '/api/notification/events', + path: '/api/v1/notification/events', }); const filteredResponse = await invokeGatewayRoute(handler, { - path: '/api/notification/events?source=scheduler&level=warn&hours=48&limit=5', + path: '/api/v1/notification/events?source=scheduler&level=warn&hours=48&limit=5', }); assert.equal(response.statusCode, 200); @@ -107,7 +107,7 @@ test('GET /api/risk/events returns seeded risk events', async () => { }); const response = await invokeGatewayRoute(handler, { - path: '/api/risk/events', + path: '/api/v1/risk/events', }); assert.equal(response.statusCode, 200); @@ -221,7 +221,7 @@ test('GET /api/risk/workbench returns the consolidated risk workbench snapshot', }); const response = await invokeGatewayRoute(handler, { - path: '/api/risk/workbench?hours=168&limit=10', + path: '/api/v1/risk/workbench?hours=168&limit=10', }); assert.equal(response.statusCode, 200); @@ -317,7 +317,7 @@ test('POST /api/risk/actions executes risk policy actions and leaves policy trac const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/risk/actions', + path: '/api/v1/risk/actions', body: { actionKey: 'release-emergency-brake', actor: 'risk-operator', @@ -362,7 +362,7 @@ test('GET /api/risk/events/:id returns a single risk event', async () => { }); const response = await invokeGatewayRoute(handler, { - path: '/api/risk/events/risk-api-detail', + path: '/api/v1/risk/events/risk-api-detail', }); assert.equal(response.statusCode, 200); @@ -371,7 +371,7 @@ test('GET /api/risk/events/:id returns a single risk event', async () => { test('GET /api/strategy/catalog returns research strategies', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/strategy/catalog', + path: '/api/v1/strategy/catalog', }); assert.equal(response.statusCode, 200); @@ -386,7 +386,7 @@ test('GET /api/strategy/catalog returns research strategies', async () => { test('POST /api/strategy/catalog saves strategy catalog entries', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/strategy/catalog', + path: '/api/v1/strategy/catalog', body: { id: 'stat-arb-us', name: 'US Stat Arb', @@ -420,7 +420,7 @@ test('POST /api/strategy/catalog saves strategy catalog entries', async () => { test('GET /api/strategy/catalog/:id returns strategy detail with recent runs', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/strategy/catalog/ema-cross-us', + path: '/api/v1/strategy/catalog/ema-cross-us', }); assert.equal(response.statusCode, 200); @@ -459,7 +459,7 @@ test('GET /api/market/provider-status returns backend market provider status', a }); const response = await invokeGatewayRoute(handler, { - path: '/api/market/provider-status', + path: '/api/v1/market/provider-status', }); assert.equal(response.statusCode, 200); @@ -470,7 +470,7 @@ test('GET /api/market/provider-status returns backend market provider status', a test('GET /api/backtest/summary returns structured research summary', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/backtest/summary', + path: '/api/v1/backtest/summary', }); assert.equal(response.statusCode, 200); @@ -481,7 +481,7 @@ test('GET /api/backtest/summary returns structured research summary', async () = test('GET /api/backtest/runs returns structured backtest runs', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/backtest/runs', + path: '/api/v1/backtest/runs', }); assert.equal(response.statusCode, 200); @@ -496,7 +496,7 @@ test('GET /api/backtest/runs returns structured backtest runs', async () => { test('GET /api/backtest/runs/:id returns run detail with linked strategy and workflow context', async () => { const created = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/backtest/runs', + path: '/api/v1/backtest/runs', body: { strategyId: 'ema-cross-us', windowLabel: '2024-01-01 -> 2024-12-31', @@ -505,7 +505,7 @@ test('GET /api/backtest/runs/:id returns run detail with linked strategy and wor }); const response = await invokeGatewayRoute(handler, { - path: `/api/backtest/runs/${created.json.run.id}`, + path: `/api/v1/backtest/runs/${created.json.run.id}`, }); assert.equal(response.statusCode, 200); @@ -520,7 +520,7 @@ test('GET /api/backtest/runs/:id returns run detail with linked strategy and wor test('GET /api/research/tasks returns research backbone tasks and related summary routes', async () => { const created = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/backtest/runs', + path: '/api/v1/backtest/runs', body: { strategyId: 'ema-cross-us', windowLabel: '2024-01-01 -> 2024-12-31', @@ -529,19 +529,19 @@ test('GET /api/research/tasks returns research backbone tasks and related summar }); const tasksResponse = await invokeGatewayRoute(handler, { - path: `/api/research/tasks?strategyId=ema-cross-us&workflowRunId=${created.json.workflow.id}&limit=5`, + path: `/api/v1/research/tasks?strategyId=ema-cross-us&workflowRunId=${created.json.workflow.id}&limit=5`, }); const summaryResponse = await invokeGatewayRoute(handler, { - path: '/api/research/tasks/summary?hours=168&limit=20', + path: '/api/v1/research/tasks/summary?hours=168&limit=20', }); const hubResponse = await invokeGatewayRoute(handler, { - path: '/api/research/hub?hours=168&limit=20', + path: '/api/v1/research/hub?hours=168&limit=20', }); const workbenchResponse = await invokeGatewayRoute(handler, { - path: '/api/research/workbench?hours=168&limit=20', + path: '/api/v1/research/workbench?hours=168&limit=20', }); const detailResponse = await invokeGatewayRoute(handler, { - path: `/api/research/tasks/${created.json.researchTask.id}`, + path: `/api/v1/research/tasks/${created.json.researchTask.id}`, }); assert.equal(tasksResponse.statusCode, 200); @@ -573,7 +573,7 @@ test('GET /api/research/tasks returns research backbone tasks and related summar test('POST /api/backtest/runs/:id/evaluate persists a research evaluation and exposes summary routes', async () => { const created = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/backtest/runs', + path: '/api/v1/backtest/runs', body: { strategyId: 'ema-cross-us', windowLabel: '2024-01-01 -> 2024-12-31', @@ -583,7 +583,7 @@ test('POST /api/backtest/runs/:id/evaluate persists a research evaluation and ex await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/backtest/runs/${created.json.run.id}/review`, + path: `/api/v1/backtest/runs/${created.json.run.id}/review`, body: { reviewedBy: 'risk-operator', summary: 'Reviewed and ready for evaluation.', @@ -592,20 +592,20 @@ test('POST /api/backtest/runs/:id/evaluate persists a research evaluation and ex const evaluated = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/backtest/runs/${created.json.run.id}/evaluate`, + path: `/api/v1/backtest/runs/${created.json.run.id}/evaluate`, body: { actor: 'research-lead', summary: 'Research lead marked this result ready for promotion.', }, }); const detail = await invokeGatewayRoute(handler, { - path: `/api/backtest/runs/${created.json.run.id}`, + path: `/api/v1/backtest/runs/${created.json.run.id}`, }); const feed = await invokeGatewayRoute(handler, { - path: `/api/research/evaluations?runId=${created.json.run.id}`, + path: `/api/v1/research/evaluations?runId=${created.json.run.id}`, }); const summary = await invokeGatewayRoute(handler, { - path: '/api/research/evaluations/summary?strategyId=ema-cross-us', + path: '/api/v1/research/evaluations/summary?strategyId=ema-cross-us', }); assert.equal(evaluated.statusCode, 200); @@ -623,7 +623,7 @@ test('POST /api/backtest/runs/:id/evaluate persists a research evaluation and ex test('POST /api/research/governance/actions runs batch governance actions and exposes them through the workbench', async () => { const created = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/backtest/runs', + path: '/api/v1/backtest/runs', body: { strategyId: 'ema-cross-us', windowLabel: '2024-01-01 -> 2024-12-31', @@ -633,7 +633,7 @@ test('POST /api/research/governance/actions runs batch governance actions and ex await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/backtest/runs/${created.json.run.id}/review`, + path: `/api/v1/backtest/runs/${created.json.run.id}/review`, body: { reviewedBy: 'risk-operator', summary: 'Reviewed for governance evaluation.', @@ -642,7 +642,7 @@ test('POST /api/research/governance/actions runs batch governance actions and ex const evaluateResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/research/governance/actions', + path: '/api/v1/research/governance/actions', body: { action: 'evaluate_runs', actor: 'research-governance', @@ -651,7 +651,7 @@ test('POST /api/research/governance/actions runs batch governance actions and ex }); const refreshResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/research/governance/actions', + path: '/api/v1/research/governance/actions', body: { action: 'queue_backtests', actor: 'research-governance', @@ -661,7 +661,7 @@ test('POST /api/research/governance/actions runs batch governance actions and ex }); const baselineResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/research/governance/actions', + path: '/api/v1/research/governance/actions', body: { action: 'set_baseline', actor: 'research-governance', @@ -670,7 +670,7 @@ test('POST /api/research/governance/actions runs batch governance actions and ex }); const championResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/research/governance/actions', + path: '/api/v1/research/governance/actions', body: { action: 'set_champion', actor: 'research-governance', @@ -678,7 +678,7 @@ test('POST /api/research/governance/actions runs batch governance actions and ex }, }); const workbenchResponse = await invokeGatewayRoute(handler, { - path: '/api/research/workbench?hours=168&limit=20', + path: '/api/v1/research/workbench?hours=168&limit=20', }); assert.equal(evaluateResponse.statusCode, 200); @@ -703,7 +703,7 @@ test('POST /api/research/governance/actions runs batch governance actions and ex test('POST /api/research/execution-candidates creates a persisted handoff and queues execution workflow from it', async () => { const created = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/research/execution-candidates', + path: '/api/v1/research/execution-candidates', body: { strategyId: 'ema-cross-us', actor: 'research-lead', @@ -712,11 +712,11 @@ test('POST /api/research/execution-candidates creates a persisted handoff and qu }, }); const listed = await invokeGatewayRoute(handler, { - path: '/api/research/execution-candidates?limit=10', + path: '/api/v1/research/execution-candidates?limit=10', }); const queued = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/research/execution-candidates/${created.json.handoff.id}/queue`, + path: `/api/v1/research/execution-candidates/${created.json.handoff.id}/queue`, body: { actor: 'execution-desk', owner: 'execution-desk', @@ -739,10 +739,10 @@ test('POST /api/research/execution-candidates creates a persisted handoff and qu test('GET /api/research/reports and summary return report assets generated for research operations', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/research/reports?strategyId=ema-cross-us&limit=10', + path: '/api/v1/research/reports?strategyId=ema-cross-us&limit=10', }); const summary = await invokeGatewayRoute(handler, { - path: '/api/research/reports/summary?strategyId=ema-cross-us&limit=20', + path: '/api/v1/research/reports/summary?strategyId=ema-cross-us&limit=20', }); assert.equal(response.statusCode, 200); @@ -755,7 +755,7 @@ test('GET /api/research/reports and summary return report assets generated for r test('POST /api/strategy/catalog/:id/promote uses the latest research evaluation as a guardrail', async () => { const reviewed = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/backtest/runs/bt-ema-cross-20260310/review', + path: '/api/v1/backtest/runs/bt-ema-cross-20260310/review', body: { reviewedBy: 'risk-operator', summary: 'Reviewed seed run for promotion guardrail.', @@ -766,7 +766,7 @@ test('POST /api/strategy/catalog/:id/promote uses the latest research evaluation const evaluated = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/backtest/runs/bt-ema-cross-20260310/evaluate', + path: '/api/v1/backtest/runs/bt-ema-cross-20260310/evaluate', body: { actor: 'research-lead', summary: 'Seed strategy is ready for paper promotion.', @@ -774,7 +774,7 @@ test('POST /api/strategy/catalog/:id/promote uses the latest research evaluation }); const promoted = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/strategy/catalog/ema-cross-us/promote', + path: '/api/v1/strategy/catalog/ema-cross-us/promote', body: { actor: 'api-test', nextStatus: 'paper', @@ -791,7 +791,7 @@ test('POST /api/strategy/catalog/:id/promote uses the latest research evaluation test('GET /api/backtest/results exposes versioned backtest results and detail context', async () => { const created = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/backtest/runs', + path: '/api/v1/backtest/runs', body: { strategyId: 'ema-cross-us', windowLabel: '2024-01-01 -> 2024-12-31', @@ -812,7 +812,7 @@ test('GET /api/backtest/results exposes versioned backtest results and detail co const reviewResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/backtest/runs/${created.json.run.id}/review`, + path: `/api/v1/backtest/runs/${created.json.run.id}/review`, body: { reviewedBy: 'risk-operator', summary: 'Operator accepted the result after reviewing the drawdown explanation.', @@ -820,13 +820,13 @@ test('GET /api/backtest/results exposes versioned backtest results and detail co }); const listResponse = await invokeGatewayRoute(handler, { - path: `/api/backtest/results?runId=${created.json.run.id}&limit=10`, + path: `/api/v1/backtest/results?runId=${created.json.run.id}&limit=10`, }); const summaryResponse = await invokeGatewayRoute(handler, { - path: `/api/backtest/results/summary?strategyId=ema-cross-us&limit=20`, + path: `/api/v1/backtest/results/summary?strategyId=ema-cross-us&limit=20`, }); const detailResponse = await invokeGatewayRoute(handler, { - path: `/api/backtest/results/${reviewResponse.json.latestResult.id}`, + path: `/api/v1/backtest/results/${reviewResponse.json.latestResult.id}`, }); assert.equal(listResponse.statusCode, 200); @@ -845,7 +845,7 @@ test('GET /api/backtest/results exposes versioned backtest results and detail co test('POST /api/backtest/runs queues a persisted research workflow run', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/backtest/runs', + path: '/api/v1/backtest/runs', body: { strategyId: 'ema-cross-us', windowLabel: '2024-01-01 -> 2024-12-31', @@ -863,7 +863,7 @@ test('POST /api/backtest/runs queues a persisted research workflow run', async ( test('POST /api/backtest/runs/:id/review updates reviewable backtest runs', async () => { const created = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/backtest/runs', + path: '/api/v1/backtest/runs', body: { strategyId: 'rsi-revert-index', windowLabel: '2023-01-01 -> 2024-12-31', @@ -879,7 +879,7 @@ test('POST /api/backtest/runs/:id/review updates reviewable backtest runs', asyn const reviewResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/backtest/runs/${created.json.run.id}/review`, + path: `/api/v1/backtest/runs/${created.json.run.id}/review`, body: { reviewedBy: 'risk-operator', summary: 'Operator accepted the run for promotion review.', @@ -902,7 +902,7 @@ test('POST /api/backtest/runs/:id/review updates reviewable backtest runs', asyn test('GET /api/agent/tools returns allowlisted read-only tools', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/agent/tools', + path: '/api/v1/agent/tools', }); assert.equal(response.statusCode, 200); @@ -920,7 +920,7 @@ test('GET /api/agent/tools returns allowlisted read-only tools', async () => { test('GET /api/architecture returns the seven-layer architecture summary', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/architecture', + path: '/api/v1/architecture', }); assert.equal(response.statusCode, 200); @@ -946,7 +946,7 @@ test('GET /api/architecture returns the seven-layer architecture summary', async test('GET /api/auth/session returns account-backed session data', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/auth/session', + path: '/api/v1/auth/session', }); assert.equal(response.statusCode, 200); @@ -959,7 +959,7 @@ test('GET /api/auth/session returns account-backed session data', async () => { test('GET /api/auth/permissions returns the shared permission catalog', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/auth/permissions', + path: '/api/v1/auth/permissions', }); assert.equal(response.statusCode, 200); @@ -981,7 +981,7 @@ test('GET /api/auth/permissions returns the shared permission catalog', async () test('GET /api/user-account/profile returns profile and preferences', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/user-account/profile', + path: '/api/v1/user-account/profile', }); assert.equal(response.statusCode, 200); @@ -997,7 +997,7 @@ test('GET /api/user-account/profile returns profile and preferences', async () = test('GET /api/user-account/roles returns persisted role templates', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/user-account/roles', + path: '/api/v1/user-account/roles', }); assert.equal(response.statusCode, 200); @@ -1011,7 +1011,7 @@ test('GET /api/user-account/roles returns persisted role templates', async () => test('GET /api/user-account returns consolidated account workspace data', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/user-account', + path: '/api/v1/user-account', }); assert.equal(response.statusCode, 200); @@ -1028,7 +1028,7 @@ test('GET /api/user-account returns consolidated account workspace data', async test('GET /api/user-account/workspaces returns tenant and workspace memberships', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/user-account/workspaces', + path: '/api/v1/user-account/workspaces', }); assert.equal(response.statusCode, 200); @@ -1041,7 +1041,7 @@ test('GET /api/user-account/workspaces returns tenant and workspace memberships' test('POST /api/user-account/access updates persisted access policy and session permissions', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/access', + path: '/api/v1/user-account/access', body: { role: 'operator', grants: ['execution:approve'], @@ -1064,7 +1064,7 @@ test('POST /api/user-account/access updates persisted access policy and session assert.equal(response.json.session.user.role, 'operator'); const sessionResponse = await invokeGatewayRoute(handler, { - path: '/api/auth/session', + path: '/api/v1/auth/session', }); assert.equal(sessionResponse.statusCode, 200); assert.equal(sessionResponse.json.user.role, 'operator'); @@ -1074,7 +1074,7 @@ test('POST /api/user-account/access updates persisted access policy and session assert.equal(sessionResponse.json.user.permissions.includes('strategy:write'), false); const auditResponse = await invokeGatewayRoute(handler, { - path: '/api/audit/records', + path: '/api/v1/audit/records', }); assert.equal(auditResponse.statusCode, 200); assert.equal( @@ -1100,7 +1100,7 @@ test('POST /api/user-account/access updates persisted access policy and session test('POST and DELETE /api/user-account/roles persist custom role templates', async () => { const createResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/roles', + path: '/api/v1/user-account/roles', body: { id: 'quant-analyst', label: 'Quant Analyst', @@ -1119,7 +1119,7 @@ test('POST and DELETE /api/user-account/roles persist custom role templates', as const deleteResponse = await invokeGatewayRoute(handler, { method: 'DELETE', - path: '/api/user-account/roles/quant-analyst', + path: '/api/v1/user-account/roles/quant-analyst', }); assert.equal(deleteResponse.statusCode, 200); @@ -1133,7 +1133,7 @@ test('POST and DELETE /api/user-account/roles persist custom role templates', as test('POST /api/user-account/workspaces and /current persist workspace scope selection', async () => { const createResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/workspaces', + path: '/api/v1/user-account/workspaces', body: { id: 'workspace-live-ops', key: 'live-ops', @@ -1154,7 +1154,7 @@ test('POST /api/user-account/workspaces and /current persist workspace scope sel const selectResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/workspaces/current', + path: '/api/v1/user-account/workspaces/current', body: { workspaceId: 'workspace-live-ops', }, @@ -1180,7 +1180,7 @@ test('POST /api/user-account/workspaces and /current persist workspace scope sel ); const auditResponse = await invokeGatewayRoute(handler, { - path: '/api/audit/records', + path: '/api/v1/audit/records', }); assert.equal(auditResponse.statusCode, 200); assert.equal( @@ -1193,14 +1193,14 @@ test('POST /api/user-account/workspaces and /current persist workspace scope sel ); const sessionResponse = await invokeGatewayRoute(handler, { - path: '/api/auth/session', + path: '/api/v1/auth/session', }); assert.equal(sessionResponse.statusCode, 200); assert.equal(sessionResponse.json.workspace.id, 'workspace-live-ops'); await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/workspaces/current', + path: '/api/v1/user-account/workspaces/current', body: { workspaceId: 'workspace-operations', }, @@ -1210,7 +1210,7 @@ test('POST /api/user-account/workspaces and /current persist workspace scope sel test('POST /api/user-account/profile updates persisted profile data', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/profile', + path: '/api/v1/user-account/profile', body: { name: 'Operator One', organization: 'QuantPilot Research', @@ -1223,7 +1223,7 @@ test('POST /api/user-account/profile updates persisted profile data', async () = assert.equal(response.json.profile.organization, 'QuantPilot Research'); const auditResponse = await invokeGatewayRoute(handler, { - path: '/api/audit/records', + path: '/api/v1/audit/records', }); assert.equal(auditResponse.statusCode, 200); assert.equal( @@ -1238,7 +1238,7 @@ test('POST /api/user-account/profile updates persisted profile data', async () = test('POST /api/user-account/preferences updates persisted preferences', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/preferences', + path: '/api/v1/user-account/preferences', body: { locale: 'en-US', notificationChannels: ['inbox', 'email'], @@ -1251,7 +1251,7 @@ test('POST /api/user-account/preferences updates persisted preferences', async ( assert.equal(response.json.preferences.notificationChannels.includes('email'), true); const auditResponse = await invokeGatewayRoute(handler, { - path: '/api/audit/records', + path: '/api/v1/audit/records', }); assert.equal(auditResponse.statusCode, 200); assert.equal( @@ -1271,7 +1271,7 @@ test('account write routes reject requests without account:write permission', as const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/preferences', + path: '/api/v1/user-account/preferences', body: { locale: 'zh-CN', }, @@ -1300,7 +1300,7 @@ test('account write routes reject requests without account:write permission', as test('POST /api/user-account/broker-bindings upserts broker bindings', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/broker-bindings', + path: '/api/v1/user-account/broker-bindings', body: { id: 'binding-live', provider: 'custom-http', @@ -1319,7 +1319,7 @@ test('POST /api/user-account/broker-bindings upserts broker bindings', async () assert.equal(response.json.summary.total >= 1, true); const listResponse = await invokeGatewayRoute(handler, { - path: '/api/user-account/broker-bindings', + path: '/api/v1/user-account/broker-bindings', }); assert.equal(listResponse.statusCode, 200); assert.equal(listResponse.json.ok, true); @@ -1330,7 +1330,7 @@ test('POST /api/user-account/broker-bindings upserts broker bindings', async () assert.equal(typeof listResponse.json.summary.requiresAttention, 'number'); const auditResponse = await invokeGatewayRoute(handler, { - path: '/api/audit/records', + path: '/api/v1/audit/records', }); assert.equal(auditResponse.statusCode, 200); assert.equal( @@ -1346,7 +1346,7 @@ test('POST /api/user-account/broker-bindings upserts broker bindings', async () test('POST /api/user-account/broker-bindings/:id/default switches the default binding', async () => { await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/broker-bindings', + path: '/api/v1/user-account/broker-bindings', body: { id: 'binding-paper', provider: 'alpaca', @@ -1361,7 +1361,7 @@ test('POST /api/user-account/broker-bindings/:id/default switches the default bi const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/broker-bindings/binding-paper/default', + path: '/api/v1/user-account/broker-bindings/binding-paper/default', body: {}, }); @@ -1372,7 +1372,7 @@ test('POST /api/user-account/broker-bindings/:id/default switches the default bi assert.equal(response.json.bindings.filter((item) => item.isDefault).length, 1); const auditResponse = await invokeGatewayRoute(handler, { - path: '/api/audit/records', + path: '/api/v1/audit/records', }); assert.equal(auditResponse.statusCode, 200); assert.equal( @@ -1388,7 +1388,7 @@ test('POST /api/user-account/broker-bindings/:id/default switches the default bi test('DELETE /api/user-account/broker-bindings/:id removes a non-default binding', async () => { await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/broker-bindings', + path: '/api/v1/user-account/broker-bindings', body: { id: 'binding-delete', provider: 'custom-http', @@ -1403,7 +1403,7 @@ test('DELETE /api/user-account/broker-bindings/:id removes a non-default binding const response = await invokeGatewayRoute(handler, { method: 'DELETE', - path: '/api/user-account/broker-bindings/binding-delete', + path: '/api/v1/user-account/broker-bindings/binding-delete', }); assert.equal(response.statusCode, 200); @@ -1415,7 +1415,7 @@ test('DELETE /api/user-account/broker-bindings/:id removes a non-default binding ); const auditResponse = await invokeGatewayRoute(handler, { - path: '/api/audit/records', + path: '/api/v1/audit/records', }); assert.equal(auditResponse.statusCode, 200); assert.equal( @@ -1430,13 +1430,13 @@ test('DELETE /api/user-account/broker-bindings/:id removes a non-default binding test('DELETE /api/user-account/broker-bindings/:id rejects deleting the default binding', async () => { const listResponse = await invokeGatewayRoute(handler, { - path: '/api/user-account/broker-bindings', + path: '/api/v1/user-account/broker-bindings', }); const defaultBinding = listResponse.json.bindings.find((item) => item.isDefault); const response = await invokeGatewayRoute(handler, { method: 'DELETE', - path: `/api/user-account/broker-bindings/${defaultBinding.id}`, + path: `/api/v1/user-account/broker-bindings/${defaultBinding.id}`, }); assert.equal(response.statusCode, 409); @@ -1446,7 +1446,7 @@ test('DELETE /api/user-account/broker-bindings/:id rejects deleting the default test('GET /api/user-account/broker-bindings/runtime returns default binding runtime health', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/user-account/broker-bindings/runtime', + path: '/api/v1/user-account/broker-bindings/runtime', }); assert.equal(response.statusCode, 200); @@ -1458,7 +1458,7 @@ test('GET /api/user-account/broker-bindings/runtime returns default binding runt test('POST /api/user-account/broker-bindings/sync updates default binding runtime status', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/user-account/broker-bindings/sync', + path: '/api/v1/user-account/broker-bindings/sync', body: {}, }); @@ -1469,7 +1469,7 @@ test('POST /api/user-account/broker-bindings/sync updates default binding runtim assert.equal(typeof response.json.binding.lastSyncAt, 'string'); const auditResponse = await invokeGatewayRoute(handler, { - path: '/api/audit/records', + path: '/api/v1/audit/records', }); assert.equal(auditResponse.statusCode, 200); assert.equal( @@ -1485,7 +1485,7 @@ test('POST /api/user-account/broker-bindings/sync updates default binding runtim test('POST /api/agent/tools/execute runs an allowlisted read-only tool', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/tools/execute', + path: '/api/v1/agent/tools/execute', body: { tool: 'backtest.summary.get', }, @@ -1500,7 +1500,7 @@ test('POST /api/agent/tools/execute runs an allowlisted read-only tool', async ( test('POST /api/agent/tools/execute rejects non-allowlisted tools', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/tools/execute', + path: '/api/v1/agent/tools/execute', body: { tool: 'execution.plan.create', }, @@ -1513,7 +1513,7 @@ test('POST /api/agent/tools/execute rejects non-allowlisted tools', async () => test('POST /api/agent/intent parses execution-prep prompts into persisted agent sessions', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/intent', + path: '/api/v1/agent/intent', body: { prompt: '请在开盘前为 ema-cross-us 准备执行计划,并确认是否需要审批。', requestedBy: 'operator-demo', @@ -1536,7 +1536,7 @@ test('POST /api/agent/intent parses execution-prep prompts into persisted agent test('POST /api/agent/intent rejects empty prompts', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/intent', + path: '/api/v1/agent/intent', body: { prompt: ' ', }, @@ -1550,7 +1550,7 @@ test('POST /api/agent/intent rejects empty prompts', async () => { test('POST /api/agent/plans creates a persisted plan with structured steps', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/plans', + path: '/api/v1/agent/plans', body: { prompt: 'Explain the latest risk posture for ema-cross-us and tell me what to review next.', requestedBy: 'operator-demo', @@ -1582,7 +1582,7 @@ test('POST /api/agent/plans creates a persisted plan with structured steps', asy test('POST /api/agent/analysis-runs executes a planned read-only analysis and persists the run', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/analysis-runs', + path: '/api/v1/agent/analysis-runs', body: { prompt: 'Prepare execution for ema-cross-us and explain whether anything is still blocking it.', @@ -1613,7 +1613,7 @@ test('POST /api/agent/analysis-runs executes a planned read-only analysis and pe test('GET /api/agent/sessions and detail expose persisted plans and analysis runs', async () => { const createResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/analysis-runs', + path: '/api/v1/agent/analysis-runs', body: { prompt: 'Explain the latest risk posture for ema-cross-us.', requestedBy: 'operator-demo', @@ -1621,10 +1621,10 @@ test('GET /api/agent/sessions and detail expose persisted plans and analysis run }); const listResponse = await invokeGatewayRoute(handler, { - path: '/api/agent/sessions?limit=5', + path: '/api/v1/agent/sessions?limit=5', }); const detailResponse = await invokeGatewayRoute(handler, { - path: `/api/agent/sessions/${createResponse.json.session.id}`, + path: `/api/v1/agent/sessions/${createResponse.json.session.id}`, }); assert.equal(listResponse.statusCode, 200); @@ -1671,7 +1671,7 @@ test('GET /api/agent/sessions respects the current workspace scope by default', context.userAccount.setCurrentWorkspace('workspace-operations'); const operationsResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/analysis-runs', + path: '/api/v1/agent/analysis-runs', body: { prompt: 'Explain the latest risk posture for workspace operations.', requestedBy: 'operator-demo', @@ -1688,7 +1688,7 @@ test('GET /api/agent/sessions respects the current workspace scope by default', context.userAccount.setCurrentWorkspace('workspace-live-ops-scope'); const liveResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/analysis-runs', + path: '/api/v1/agent/analysis-runs', body: { prompt: 'Explain the latest execution posture for live ops.', requestedBy: 'operator-demo', @@ -1696,10 +1696,10 @@ test('GET /api/agent/sessions respects the current workspace scope by default', }); const scopedList = await invokeGatewayRoute(handler, { - path: '/api/agent/sessions?limit=10', + path: '/api/v1/agent/sessions?limit=10', }); const hiddenDetail = await invokeGatewayRoute(handler, { - path: `/api/agent/sessions/${operationsResponse.json.session.id}`, + path: `/api/v1/agent/sessions/${operationsResponse.json.session.id}`, }); assert.equal(scopedList.statusCode, 200); @@ -1719,7 +1719,7 @@ test('GET /api/agent/sessions respects the current workspace scope by default', test('GET /api/agent/workbench returns explanation queues and operator trail', async () => { const createResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/analysis-runs', + path: '/api/v1/agent/analysis-runs', body: { prompt: 'Explain the latest risk posture for ema-cross-us.', requestedBy: 'operator-demo', @@ -1741,7 +1741,7 @@ test('GET /api/agent/workbench returns explanation queues and operator trail', a }); const response = await invokeGatewayRoute(handler, { - path: '/api/agent/workbench?limit=10', + path: '/api/v1/agent/workbench?limit=10', }); assert.equal(response.statusCode, 200); @@ -1765,7 +1765,7 @@ test('GET /api/agent/workbench returns explanation queues and operator trail', a test('GET /api/agent/sessions/:id/timeline returns linked operator events', async () => { const analysisResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/analysis-runs', + path: '/api/v1/agent/analysis-runs', body: { prompt: 'Prepare execution for ema-cross-us and explain whether it is still gated.', requestedBy: 'operator-demo', @@ -1786,7 +1786,7 @@ test('GET /api/agent/sessions/:id/timeline returns linked operator events', asyn }); await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/agent/action-requests/${request.id}/approve`, + path: `/api/v1/agent/action-requests/${request.id}/approve`, body: { approvedBy: 'risk-operator', mode: 'paper', @@ -1795,10 +1795,10 @@ test('GET /api/agent/sessions/:id/timeline returns linked operator events', asyn }); const timelineResponse = await invokeGatewayRoute(handler, { - path: `/api/agent/sessions/${analysisResponse.json.session.id}/timeline?limit=10`, + path: `/api/v1/agent/sessions/${analysisResponse.json.session.id}/timeline?limit=10`, }); const detailResponse = await invokeGatewayRoute(handler, { - path: `/api/agent/sessions/${analysisResponse.json.session.id}`, + path: `/api/v1/agent/sessions/${analysisResponse.json.session.id}`, }); assert.equal(timelineResponse.statusCode, 200); @@ -1828,7 +1828,7 @@ test('GET /api/agent/sessions/:id/timeline returns linked operator events', asyn test('POST /api/agent/action-requests queues an agent action request workflow', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/action-requests', + path: '/api/v1/agent/action-requests', body: { requestType: 'prepare_execution_plan', targetId: 'ema-cross-us', @@ -1915,7 +1915,7 @@ test('POST /api/agent/sessions/:id/action-requests queues a controlled handoff f const response = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/agent/sessions/${session.id}/action-requests`, + path: `/api/v1/agent/sessions/${session.id}/action-requests`, body: { requestedBy: 'operator-demo', }, @@ -1935,7 +1935,7 @@ test('POST /api/agent/sessions/:id/action-requests queues a controlled handoff f test('POST /api/agent/action-requests rejects unsupported request types', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/action-requests', + path: '/api/v1/agent/action-requests', body: { requestType: 'direct_execute', targetId: 'ema-cross-us', @@ -1956,7 +1956,7 @@ test('POST /api/agent/action-requests requires strategy:write permission', async const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/action-requests', + path: '/api/v1/agent/action-requests', body: { requestType: 'prepare_execution_plan', targetId: 'ema-cross-us', @@ -1994,7 +1994,7 @@ test('GET /api/agent/action-requests returns persisted requests', async () => { }); const response = await invokeGatewayRoute(handler, { - path: '/api/agent/action-requests', + path: '/api/v1/agent/action-requests', }); assert.equal(response.statusCode, 200); @@ -2006,7 +2006,7 @@ test('GET /api/agent/action-requests returns persisted requests', async () => { test('POST /api/agent/action-requests/approve queues downstream workflow only after approval', async () => { const createResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/action-requests', + path: '/api/v1/agent/action-requests', body: { requestType: 'prepare_execution_plan', targetId: 'ema-cross-us', @@ -2034,7 +2034,7 @@ test('POST /api/agent/action-requests/approve queues downstream workflow only af const approveResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/agent/action-requests/${request.id}/approve`, + path: `/api/v1/agent/action-requests/${request.id}/approve`, body: { approvedBy: 'risk-operator', mode: 'paper', @@ -2080,7 +2080,7 @@ test('POST /api/agent/action-requests/:id/approve links the approved request bac const approveResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/agent/action-requests/${request.id}/approve`, + path: `/api/v1/agent/action-requests/${request.id}/approve`, body: { approvedBy: 'risk-operator', mode: 'paper', @@ -2109,7 +2109,7 @@ test('POST /api/agent/action-requests/reject marks the request as rejected', asy const response = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/agent/action-requests/${request.id}/reject`, + path: `/api/v1/agent/action-requests/${request.id}/reject`, body: { rejectedBy: 'risk-operator', reason: 'Not enough context', @@ -2141,7 +2141,7 @@ test('POST /api/agent/action-requests/:id/approve requires risk:review permissio const response = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/agent/action-requests/${request.id}/approve`, + path: `/api/v1/agent/action-requests/${request.id}/approve`, body: { approvedBy: 'operator-without-risk-review', mode: 'paper', @@ -2170,7 +2170,7 @@ test('POST /api/agent/action-requests/:id/approve requires risk:review permissio test('POST /api/strategy/execute queues a strategy execution workflow', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/strategy/execute', + path: '/api/v1/strategy/execute', body: { strategyId: 'ema-cross-us', mode: 'paper', @@ -2194,7 +2194,7 @@ test('POST /api/strategy/execute requires strategy:write permission', async () = const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/strategy/execute', + path: '/api/v1/strategy/execute', body: { strategyId: 'ema-cross-us', mode: 'paper', @@ -2237,7 +2237,7 @@ test('GET /api/execution/plans returns persisted execution plans', async () => { }); const response = await invokeGatewayRoute(handler, { - path: '/api/execution/plans', + path: '/api/v1/execution/plans', }); assert.equal(response.statusCode, 200); @@ -2287,7 +2287,7 @@ test('GET /api/execution/plans/:id returns a single execution plan with workflow }); const response = await invokeGatewayRoute(handler, { - path: '/api/execution/plans/exec-plan-detail', + path: '/api/v1/execution/plans/exec-plan-detail', }); assert.equal(response.statusCode, 200); @@ -2299,7 +2299,7 @@ test('GET /api/execution/plans/:id returns a single execution plan with workflow test('GET /api/execution/plans/:id returns 404 for unknown plans', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/execution/plans/unknown-plan-id', + path: '/api/v1/execution/plans/unknown-plan-id', }); assert.equal(response.statusCode, 404); @@ -2320,7 +2320,7 @@ test('GET /api/execution/runtime returns persisted execution runtime events', as }); const response = await invokeGatewayRoute(handler, { - path: '/api/execution/runtime', + path: '/api/v1/execution/runtime', }); assert.equal(response.statusCode, 200); @@ -2340,7 +2340,7 @@ test('GET /api/execution/account-snapshots returns broker account snapshots', as }); const response = await invokeGatewayRoute(handler, { - path: '/api/execution/account-snapshots', + path: '/api/v1/execution/account-snapshots', }); assert.equal(response.statusCode, 200); @@ -2371,7 +2371,7 @@ test('GET /api/execution/account-snapshots/latest returns the latest broker snap }); const response = await invokeGatewayRoute(handler, { - path: '/api/execution/account-snapshots/latest', + path: '/api/v1/execution/account-snapshots/latest', }); assert.equal(response.statusCode, 200); @@ -2397,7 +2397,7 @@ test('GET /api/execution/broker-events returns persisted broker execution events }); const response = await invokeGatewayRoute(handler, { - path: '/api/execution/broker-events?executionPlanId=broker-event-plan&eventType=acknowledged&limit=5', + path: '/api/v1/execution/broker-events?executionPlanId=broker-event-plan&eventType=acknowledged&limit=5', }); assert.equal(response.statusCode, 200); @@ -2441,7 +2441,7 @@ test('GET /api/execution/ledger returns plans joined with workflow and runtime s }); const response = await invokeGatewayRoute(handler, { - path: '/api/execution/ledger', + path: '/api/v1/execution/ledger', }); assert.equal(response.statusCode, 200); @@ -2506,7 +2506,7 @@ test('GET /api/execution/workbench returns lifecycle summary and execution ledge }); const response = await invokeGatewayRoute(handler, { - path: '/api/execution/workbench', + path: '/api/v1/execution/workbench', }); assert.equal(response.statusCode, 200); @@ -2599,7 +2599,7 @@ test('POST /api/execution/plans/:id/approve transitions awaiting plans into subm const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/execution/plans/exec-approve-plan/approve', + path: '/api/v1/execution/plans/exec-approve-plan/approve', body: { actor: 'execution-desk', }, @@ -2676,7 +2676,7 @@ test('POST /api/execution/plans/bulk runs approval actions across multiple execu const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/execution/plans/bulk', + path: '/api/v1/execution/plans/bulk', body: { actor: 'execution-desk', action: 'approve', @@ -2746,7 +2746,7 @@ test('POST /api/execution/plans/:id/settle moves submitted plans into filled lif const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/execution/plans/exec-settle-plan/settle', + path: '/api/v1/execution/plans/exec-settle-plan/settle', body: { actor: 'execution-desk', outcome: 'filled', @@ -2805,7 +2805,7 @@ test('POST /api/execution/plans/:id/sync advances submitted plans into broker ac const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/execution/plans/exec-sync-plan/sync', + path: '/api/v1/execution/plans/exec-sync-plan/sync', body: { actor: 'execution-desk', scenario: 'acknowledge', @@ -2865,7 +2865,7 @@ test('POST /api/execution/plans/:id/cancel cancels active plans before settlemen const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/execution/plans/exec-cancel-plan/cancel', + path: '/api/v1/execution/plans/exec-cancel-plan/cancel', body: { actor: 'execution-desk', reason: 'operator_cancelled', @@ -2973,7 +2973,7 @@ test('POST /api/execution/plans/:id/reconcile records structured reconciliation const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/execution/plans/exec-reconcile-plan/reconcile', + path: '/api/v1/execution/plans/exec-reconcile-plan/reconcile', body: { actor: 'execution-desk', }, @@ -3040,7 +3040,7 @@ test('POST /api/execution/plans/:id/recover reroutes cancelled plans back into e const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/execution/plans/exec-recover-plan/recover', + path: '/api/v1/execution/plans/exec-recover-plan/recover', body: { actor: 'execution-desk', }, @@ -3149,7 +3149,7 @@ test('POST /api/execution/plans/:id/compensate runs execution compensation autom const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/execution/plans/exec-compensate-plan/compensate', + path: '/api/v1/execution/plans/exec-compensate-plan/compensate', body: { actor: 'execution-desk', }, @@ -3214,7 +3214,7 @@ test('POST /api/execution/plans/:id/broker-events ingests a broker fill event in const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/execution/plans/exec-broker-event-plan/broker-events', + path: '/api/v1/execution/plans/exec-broker-event-plan/broker-events', body: { actor: 'broker-webhook', source: 'broker-webhook', @@ -3286,7 +3286,7 @@ test('repeated broker rejects escalate execution exceptions into incident linkag const firstReject = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/execution/plans/exec-broker-reject-plan/broker-events', + path: '/api/v1/execution/plans/exec-broker-reject-plan/broker-events', body: { actor: 'broker-webhook', source: 'broker-webhook', @@ -3301,7 +3301,7 @@ test('repeated broker rejects escalate execution exceptions into incident linkag const secondReject = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/execution/plans/exec-broker-reject-plan/broker-events', + path: '/api/v1/execution/plans/exec-broker-reject-plan/broker-events', body: { actor: 'broker-webhook', source: 'broker-webhook', @@ -3325,7 +3325,7 @@ test('repeated broker rejects escalate execution exceptions into incident linkag assert.equal(secondReject.json.incident.source, 'execution'); const detail = await invokeGatewayRoute(handler, { - path: '/api/execution/plans/exec-broker-reject-plan', + path: '/api/v1/execution/plans/exec-broker-reject-plan', }); assert.equal(detail.statusCode, 200); @@ -3336,7 +3336,7 @@ test('repeated broker rejects escalate execution exceptions into incident linkag test('POST /api/task-orchestrator/workflows/:id/resume emits workflow-control notification for recovery', async () => { const queued = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/strategy/execute', + path: '/api/v1/strategy/execute', body: { strategyId: 'ema-cross-us', mode: 'live', @@ -3352,7 +3352,7 @@ test('POST /api/task-orchestrator/workflows/:id/resume emits workflow-control no const response = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/task-orchestrator/workflows/${queued.json.workflow.id}/resume`, + path: `/api/v1/task-orchestrator/workflows/${queued.json.workflow.id}/resume`, body: {}, }); @@ -3389,10 +3389,10 @@ test('GET /api/scheduler/ticks returns scheduler ticks from shared store', async }); const response = await invokeGatewayRoute(handler, { - path: '/api/scheduler/ticks', + path: '/api/v1/scheduler/ticks', }); const filteredResponse = await invokeGatewayRoute(handler, { - path: '/api/scheduler/ticks?phase=INTRADAY&hours=168&limit=5', + path: '/api/v1/scheduler/ticks?phase=INTRADAY&hours=168&limit=5', }); assert.equal(response.statusCode, 200); @@ -3477,7 +3477,7 @@ test('GET /api/scheduler/workbench returns the scheduler operations snapshot', a }); const response = await invokeGatewayRoute(handler, { - path: '/api/scheduler/workbench?hours=168&limit=10', + path: '/api/v1/scheduler/workbench?hours=168&limit=10', }); assert.equal(response.statusCode, 200); @@ -3595,7 +3595,7 @@ test('POST /api/scheduler/actions executes scheduler orchestration actions and l const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/scheduler/actions', + path: '/api/v1/scheduler/actions', body: { actionKey: 'align-risk-window', actor: 'scheduler-operator', @@ -3640,7 +3640,7 @@ test('POST then GET /api/task-orchestrator/actions persists operator actions', a const recentWarnIso = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); const createResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/actions', + path: '/api/v1/task-orchestrator/actions', body: { type: 'approve-intent', actor: 'api-test', @@ -3651,7 +3651,7 @@ test('POST then GET /api/task-orchestrator/actions persists operator actions', a }, }); const listResponse = await invokeGatewayRoute(handler, { - path: '/api/task-orchestrator/actions', + path: '/api/v1/task-orchestrator/actions', }); assert.equal(createResponse.statusCode, 200); @@ -3674,7 +3674,7 @@ test('POST then GET /api/task-orchestrator/actions persists operator actions', a }); const filteredResponse = await invokeGatewayRoute(handler, { - path: '/api/task-orchestrator/actions?level=warn&hours=48&limit=20', + path: '/api/v1/task-orchestrator/actions?level=warn&hours=48&limit=20', }); assert.equal(filteredResponse.statusCode, 200); @@ -3693,7 +3693,7 @@ test('POST /api/task-orchestrator/actions requires execution:approve permission' const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/actions', + path: '/api/v1/task-orchestrator/actions', body: { type: 'approve-intent', actor: 'api-test', @@ -3724,7 +3724,7 @@ test('POST /api/task-orchestrator/actions requires execution:approve permission' test('GET /api/health exposes gateway module status', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/health', + path: '/api/v1/health', }); assert.equal(response.statusCode, 200); @@ -3837,7 +3837,7 @@ test('GET /api/monitoring/status returns runtime health and queue summary', asyn ]); const response = await invokeGatewayRoute(handler, { - path: '/api/monitoring/status', + path: '/api/v1/monitoring/status', }); assert.equal(response.statusCode, 200); @@ -3912,10 +3912,10 @@ test('GET /api/monitoring/snapshots and alerts return persisted monitoring histo }); const snapshotsResponse = await invokeGatewayRoute(handler, { - path: '/api/monitoring/snapshots', + path: '/api/v1/monitoring/snapshots', }); const alertsResponse = await invokeGatewayRoute(handler, { - path: '/api/monitoring/alerts', + path: '/api/v1/monitoring/alerts', }); assert.equal(snapshotsResponse.statusCode, 200); @@ -3926,10 +3926,10 @@ test('GET /api/monitoring/snapshots and alerts return persisted monitoring histo assert.equal(alertsResponse.json.alerts[0].id, 'monitoring-alert-test'); const filteredSnapshots = await invokeGatewayRoute(handler, { - path: '/api/monitoring/snapshots?status=warn&hours=168&limit=5', + path: '/api/v1/monitoring/snapshots?status=warn&hours=168&limit=5', }); const filteredAlerts = await invokeGatewayRoute(handler, { - path: '/api/monitoring/alerts?source=worker&level=warn&snapshotId=monitoring-snapshot-test&hours=168&limit=5', + path: '/api/v1/monitoring/alerts?source=worker&level=warn&snapshotId=monitoring-snapshot-test&hours=168&limit=5', }); assert.equal(filteredSnapshots.statusCode, 200); @@ -4036,7 +4036,7 @@ test('GET /api/operations/workbench returns unified operations overview', async }); const response = await invokeGatewayRoute(handler, { - path: '/api/operations/workbench?hours=24&limit=50', + path: '/api/v1/operations/workbench?hours=24&limit=50', }); assert.equal(response.statusCode, 200); @@ -4128,7 +4128,7 @@ test('GET /api/operations/maintenance returns backup posture and integrity summa }); const response = await invokeGatewayRoute(handler, { - path: '/api/operations/maintenance?limit=5', + path: '/api/v1/operations/maintenance?limit=5', }); assert.equal(response.statusCode, 200); @@ -4166,7 +4166,7 @@ test('operations maintenance routes export backups, dry-run restores, and repair const backupResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/operations/maintenance/backup', + path: '/api/v1/operations/maintenance/backup', }); assert.equal(backupResponse.statusCode, 200); @@ -4178,7 +4178,7 @@ test('operations maintenance routes export backups, dry-run restores, and repair const restoreResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/operations/maintenance/restore', + path: '/api/v1/operations/maintenance/restore', body: { dryRun: true, backup: backupResponse.json.backup, @@ -4192,7 +4192,7 @@ test('operations maintenance routes export backups, dry-run restores, and repair const repairResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/operations/maintenance/repair/workflows', + path: '/api/v1/operations/maintenance/repair/workflows', body: { worker: 'api-maintenance-worker', limit: 5, @@ -4224,7 +4224,7 @@ test('operations maintenance routes reject requests without operations:maintain }); const response = await invokeGatewayRoute(handler, { - path: '/api/operations/maintenance?limit=5', + path: '/api/v1/operations/maintenance?limit=5', }); assert.equal(response.statusCode, 403); @@ -4341,7 +4341,7 @@ test('incident routes create, update, and return incident details', async () => const created = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/incidents', + path: '/api/v1/incidents', body: { id: 'incident-api-test', title: 'Queue backlog incident', @@ -4381,14 +4381,14 @@ test('incident routes create, update, and return incident details', async () => }, }); const listed = await invokeGatewayRoute(handler, { - path: '/api/incidents?status=open&severity=warn&source=monitoring&hours=168&limit=5', + path: '/api/v1/incidents?status=open&severity=warn&source=monitoring&hours=168&limit=5', }); const summary = await invokeGatewayRoute(handler, { - path: '/api/incidents/summary?hours=168&limit=20', + path: '/api/v1/incidents/summary?hours=168&limit=20', }); const updated = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/incidents/incident-api-test', + path: '/api/v1/incidents/incident-api-test', body: { status: 'investigating', actor: 'api-operator', @@ -4396,7 +4396,7 @@ test('incident routes create, update, and return incident details', async () => }); const noted = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/incidents/incident-api-test/notes', + path: '/api/v1/incidents/incident-api-test/notes', body: { author: 'api-operator', body: 'Queue was drained by worker retry.', @@ -4404,7 +4404,7 @@ test('incident routes create, update, and return incident details', async () => }); const bulk = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/incidents/bulk', + path: '/api/v1/incidents/bulk', body: { actor: 'api-operator', incidentIds: ['incident-api-test'], @@ -4416,7 +4416,7 @@ test('incident routes create, update, and return incident details', async () => const seededTaskId = context.incidents.listIncidentTasks('incident-api-test', 10)[0].id; const createdTask = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/incidents/incident-api-test/tasks', + path: '/api/v1/incidents/incident-api-test/tasks', body: { actor: 'api-operator', detail: 'Double-check fallback queue drain metrics.', @@ -4426,14 +4426,14 @@ test('incident routes create, update, and return incident details', async () => }); const updatedTask = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/incidents/incident-api-test/tasks/${seededTaskId}`, + path: `/api/v1/incidents/incident-api-test/tasks/${seededTaskId}`, body: { actor: 'api-operator', status: 'done', }, }); const detail = await invokeGatewayRoute(handler, { - path: '/api/incidents/incident-api-test', + path: '/api/v1/incidents/incident-api-test', }); assert.equal(created.statusCode, 200); @@ -4542,7 +4542,7 @@ test('POST then GET /api/audit/records persists audit entries', async () => { const recentAuditAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); const createResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/audit/records', + path: '/api/v1/audit/records', body: { type: 'test-audit', actor: 'api-test', @@ -4551,7 +4551,7 @@ test('POST then GET /api/audit/records persists audit entries', async () => { }, }); const listResponse = await invokeGatewayRoute(handler, { - path: '/api/audit/records', + path: '/api/v1/audit/records', }); assert.equal(createResponse.statusCode, 200); @@ -4572,7 +4572,7 @@ test('POST then GET /api/audit/records persists audit entries', async () => { }); const filteredResponse = await invokeGatewayRoute(handler, { - path: '/api/audit/records?type=workflow&hours=48&limit=50', + path: '/api/v1/audit/records?type=workflow&hours=48&limit=50', }); assert.equal(filteredResponse.statusCode, 200); @@ -4585,7 +4585,7 @@ test('POST then GET /api/audit/records persists audit entries', async () => { test('POST then GET /api/task-orchestrator/cycles persists cycle records', async () => { const createResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/cycles', + path: '/api/v1/task-orchestrator/cycles', body: { cycle: 21, mode: 'hybrid', @@ -4599,7 +4599,7 @@ test('POST then GET /api/task-orchestrator/cycles persists cycle records', async }, }); const listResponse = await invokeGatewayRoute(handler, { - path: '/api/task-orchestrator/cycles', + path: '/api/v1/task-orchestrator/cycles', }); assert.equal(createResponse.statusCode, 200); @@ -4619,7 +4619,7 @@ test('POST then GET /api/task-orchestrator/cycles persists cycle records', async test('POST /api/task-orchestrator/cycles/run returns control plane resolution', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/cycles/run', + path: '/api/v1/task-orchestrator/cycles/run', body: { cycle: 22, mode: 'autopilot', @@ -4646,7 +4646,7 @@ test('POST /api/task-orchestrator/cycles/run returns control plane resolution', test('POST /api/task-orchestrator/cycles queues review notifications when approvals are pending', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/cycles', + path: '/api/v1/task-orchestrator/cycles', body: { cycle: 23, mode: 'autopilot', @@ -4672,7 +4672,7 @@ test('POST /api/task-orchestrator/cycles queues review notifications when approv test('POST /api/task-orchestrator/state/run returns next state and enqueues risk scan', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/state/run', + path: '/api/v1/task-orchestrator/state/run', body: { state: createTradingState(), }, @@ -4694,7 +4694,7 @@ test('POST /api/task-orchestrator/state/run returns next state and enqueues risk test('GET /api/task-orchestrator/workflows returns persisted workflow runs', async () => { await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/cycles/run', + path: '/api/v1/task-orchestrator/cycles/run', body: { cycle: 24, mode: 'autopilot', @@ -4711,7 +4711,7 @@ test('GET /api/task-orchestrator/workflows returns persisted workflow runs', asy }); const response = await invokeGatewayRoute(handler, { - path: '/api/task-orchestrator/workflows', + path: '/api/v1/task-orchestrator/workflows', }); assert.equal(response.statusCode, 200); @@ -4724,7 +4724,7 @@ test('GET /api/task-orchestrator/workflows returns persisted workflow runs', asy test('GET /api/task-orchestrator/workflows/:id returns a persisted workflow run', async () => { const cycleRun = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/cycles/run', + path: '/api/v1/task-orchestrator/cycles/run', body: { cycle: 25, mode: 'autopilot', @@ -4741,7 +4741,7 @@ test('GET /api/task-orchestrator/workflows/:id returns a persisted workflow run' }); const response = await invokeGatewayRoute(handler, { - path: `/api/task-orchestrator/workflows/${cycleRun.json.workflow.id}`, + path: `/api/v1/task-orchestrator/workflows/${cycleRun.json.workflow.id}`, }); assert.equal(response.statusCode, 200); @@ -4752,7 +4752,7 @@ test('GET /api/task-orchestrator/workflows/:id returns a persisted workflow run' test('POST /api/task-orchestrator/workflows/queue creates a queued workflow run', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/workflows/queue', + path: '/api/v1/task-orchestrator/workflows/queue', body: { workflowId: 'task-orchestrator.manual-review', workflowType: 'task-orchestrator', @@ -4771,7 +4771,7 @@ test('POST /api/task-orchestrator/workflows/queue creates a queued workflow run' test('POST /api/task-orchestrator/cycles/queue creates a queued cycle workflow', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/cycles/queue', + path: '/api/v1/task-orchestrator/cycles/queue', body: { cycle: 26, mode: 'hybrid', @@ -4795,7 +4795,7 @@ test('POST /api/task-orchestrator/cycles/queue creates a queued cycle workflow', test('POST /api/task-orchestrator/state/queue creates a queued state workflow', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/state/queue', + path: '/api/v1/task-orchestrator/state/queue', body: { state: createTradingState(), }, @@ -4809,7 +4809,7 @@ test('POST /api/task-orchestrator/state/queue creates a queued state workflow', test('POST /api/task-orchestrator/workflows/:id/resume resumes a failed workflow run', async () => { const queued = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/workflows/queue', + path: '/api/v1/task-orchestrator/workflows/queue', body: { workflowId: 'task-orchestrator.resume-test', workflowType: 'task-orchestrator', @@ -4826,7 +4826,7 @@ test('POST /api/task-orchestrator/workflows/:id/resume resumes a failed workflow const response = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/task-orchestrator/workflows/${queued.json.workflow.id}/resume`, + path: `/api/v1/task-orchestrator/workflows/${queued.json.workflow.id}/resume`, body: {}, }); @@ -4838,7 +4838,7 @@ test('POST /api/task-orchestrator/workflows/:id/resume resumes a failed workflow test('POST /api/task-orchestrator/workflows/:id/resume requires execution:approve permission', async () => { const queued = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/workflows/queue', + path: '/api/v1/task-orchestrator/workflows/queue', body: { workflowId: 'task-orchestrator.resume-gate-test', workflowType: 'task-orchestrator', @@ -4861,7 +4861,7 @@ test('POST /api/task-orchestrator/workflows/:id/resume requires execution:approv const response = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/task-orchestrator/workflows/${queued.json.workflow.id}/resume`, + path: `/api/v1/task-orchestrator/workflows/${queued.json.workflow.id}/resume`, body: {}, }); @@ -4886,7 +4886,7 @@ test('POST /api/task-orchestrator/workflows/:id/resume requires execution:approv test('POST /api/task-orchestrator/workflows/:id/cancel cancels a workflow run', async () => { const queued = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/workflows/queue', + path: '/api/v1/task-orchestrator/workflows/queue', body: { workflowId: 'task-orchestrator.cancel-test', workflowType: 'task-orchestrator', @@ -4897,7 +4897,7 @@ test('POST /api/task-orchestrator/workflows/:id/cancel cancels a workflow run', const response = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/task-orchestrator/workflows/${queued.json.workflow.id}/cancel`, + path: `/api/v1/task-orchestrator/workflows/${queued.json.workflow.id}/cancel`, body: {}, }); @@ -4908,7 +4908,7 @@ test('POST /api/task-orchestrator/workflows/:id/cancel cancels a workflow run', test('POST /api/task-orchestrator/workflows/:id/cancel requires execution:approve permission', async () => { const queued = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/task-orchestrator/workflows/queue', + path: '/api/v1/task-orchestrator/workflows/queue', body: { workflowId: 'task-orchestrator.cancel-gate-test', workflowType: 'task-orchestrator', @@ -4925,7 +4925,7 @@ test('POST /api/task-orchestrator/workflows/:id/cancel requires execution:approv const response = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/task-orchestrator/workflows/${queued.json.workflow.id}/cancel`, + path: `/api/v1/task-orchestrator/workflows/${queued.json.workflow.id}/cancel`, body: {}, }); @@ -4950,7 +4950,7 @@ test('POST /api/task-orchestrator/workflows/:id/cancel requires execution:approv test('POST /api/agent/instructions records a daily bias instruction', async () => { const response = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/instructions', + path: '/api/v1/agent/instructions', body: { sessionId: 'session-governance-1', kind: 'daily_bias', @@ -4967,7 +4967,7 @@ test('POST /api/agent/instructions records a daily bias instruction', async () = test('GET /api/agent/authority resolves the most restrictive mode', async () => { const response = await invokeGatewayRoute(handler, { - path: '/api/agent/authority?accountId=paper-main&strategyId=trend&actionType=enter&environment=paper', + path: '/api/v1/agent/authority?accountId=paper-main&strategyId=trend&actionType=enter&environment=paper', }); assert.equal(response.statusCode, 200); @@ -4980,7 +4980,7 @@ test('GET /api/agent/authority resolves the most restrictive mode', async () => test('POST /api/agent/policies saves a policy and GET /api/agent/authority reflects it', async () => { const saveResponse = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/policies', + path: '/api/v1/agent/policies', body: { id: 'policy-gateway-bounded', accountId: 'paper-bounded', @@ -4996,7 +4996,7 @@ test('POST /api/agent/policies saves a policy and GET /api/agent/authority refle assert.equal(saveResponse.json.policy.authority, 'bounded_auto'); const authResponse = await invokeGatewayRoute(handler, { - path: '/api/agent/authority?accountId=paper-bounded&strategyId=trend-bounded&actionType=enter&environment=paper', + path: '/api/v1/agent/authority?accountId=paper-bounded&strategyId=trend-bounded&actionType=enter&environment=paper', }); assert.equal(authResponse.statusCode, 200); diff --git a/apps/api/test/helpers/invoke-gateway.ts b/apps/api/test/helpers/invoke-gateway.ts index da6df27e..1c78b047 100644 --- a/apps/api/test/helpers/invoke-gateway.ts +++ b/apps/api/test/helpers/invoke-gateway.ts @@ -32,6 +32,9 @@ export async function invokeGatewayRoute( statusCode = code; responseHeaders = nextHeaders; }, + setHeader(name, value) { + responseHeaders[name] = value; + }, end(chunk = '') { if (ended) return; ended = true; diff --git a/apps/api/test/stage-1-baseline.test.ts b/apps/api/test/stage-1-baseline.test.ts index 44452040..c756cb81 100644 --- a/apps/api/test/stage-1-baseline.test.ts +++ b/apps/api/test/stage-1-baseline.test.ts @@ -63,9 +63,9 @@ test.after(() => { test('stage 1 baseline exposes account workspace, session, and permission catalog contracts', async () => { const [workspace, session, permissions] = await Promise.all([ - invokeGatewayRoute(handler, { path: '/api/user-account' }), - invokeGatewayRoute(handler, { path: '/api/auth/session' }), - invokeGatewayRoute(handler, { path: '/api/auth/permissions' }), + invokeGatewayRoute(handler, { path: '/api/v1/user-account' }), + invokeGatewayRoute(handler, { path: '/api/v1/auth/session' }), + invokeGatewayRoute(handler, { path: '/api/v1/auth/permissions' }), ]); assert.equal(workspace.statusCode, 200); @@ -126,7 +126,7 @@ test('stage 1 baseline exposes incident console summary and detail contracts', a await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/incidents', + path: '/api/v1/incidents', body: { id: 'stage1-incident', title: 'Stage 1 incident baseline', @@ -148,8 +148,8 @@ test('stage 1 baseline exposes incident console summary and detail contracts', a }); const [summary, detail] = await Promise.all([ - invokeGatewayRoute(handler, { path: '/api/incidents/summary?hours=168&limit=20' }), - invokeGatewayRoute(handler, { path: '/api/incidents/stage1-incident' }), + invokeGatewayRoute(handler, { path: '/api/v1/incidents/summary?hours=168&limit=20' }), + invokeGatewayRoute(handler, { path: '/api/v1/incidents/stage1-incident' }), ]); assert.equal(summary.statusCode, 200); @@ -208,7 +208,7 @@ test('stage 1 baseline exposes operations workbench aggregation contracts', asyn }); const response = await invokeGatewayRoute(handler, { - path: '/api/operations/workbench?hours=24&limit=50', + path: '/api/v1/operations/workbench?hours=24&limit=50', }); assert.equal(response.statusCode, 200); diff --git a/apps/api/test/stage-2-baseline.test.ts b/apps/api/test/stage-2-baseline.test.ts index 21087e9e..b5b23f4d 100644 --- a/apps/api/test/stage-2-baseline.test.ts +++ b/apps/api/test/stage-2-baseline.test.ts @@ -215,7 +215,7 @@ test('stage 2 baseline exposes the research hub with governance and execution ha const createHandoff = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/research/execution-candidates', + path: '/api/v1/research/execution-candidates', body: { strategyId: 'stage2-strategy', actor: 'research-lead', @@ -230,7 +230,7 @@ test('stage 2 baseline exposes the research hub with governance and execution ha assert.equal(createHandoff.json.handoff.strategyId, 'stage2-strategy'); const hub = await invokeGatewayRoute(handler, { - path: '/api/research/hub?hours=168&limit=20', + path: '/api/v1/research/hub?hours=168&limit=20', }); assert.equal(hub.statusCode, 200); @@ -257,14 +257,14 @@ test('stage 2 baseline exposes research replay and queued execution handoff cont seedResearchChain(); let list = await invokeGatewayRoute(handler, { - path: '/api/research/execution-candidates?hours=168&limit=20', + path: '/api/v1/research/execution-candidates?hours=168&limit=20', }); let handoffId = list.json.handoffs.find((item) => item.strategyId === 'stage2-strategy')?.id; if (!handoffId) { const createHandoff = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/research/execution-candidates', + path: '/api/v1/research/execution-candidates', body: { strategyId: 'stage2-strategy', actor: 'research-lead', @@ -276,7 +276,7 @@ test('stage 2 baseline exposes research replay and queued execution handoff cont assert.equal(createHandoff.statusCode, 200); handoffId = createHandoff.json.handoff?.id; list = await invokeGatewayRoute(handler, { - path: '/api/research/execution-candidates?hours=168&limit=20', + path: '/api/v1/research/execution-candidates?hours=168&limit=20', }); } @@ -284,7 +284,7 @@ test('stage 2 baseline exposes research replay and queued execution handoff cont const queue = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/research/execution-candidates/${handoffId}/queue`, + path: `/api/v1/research/execution-candidates/${handoffId}/queue`, body: { actor: 'execution-approver', owner: 'execution-desk', @@ -297,8 +297,8 @@ test('stage 2 baseline exposes research replay and queued execution handoff cont assert.equal(queue.json.workflow.workflowId, 'task-orchestrator.strategy-execution'); const [strategyDetail, workbench] = await Promise.all([ - invokeGatewayRoute(handler, { path: '/api/strategy/catalog/stage2-strategy' }), - invokeGatewayRoute(handler, { path: '/api/research/workbench?hours=168&limit=20' }), + invokeGatewayRoute(handler, { path: '/api/v1/strategy/catalog/stage2-strategy' }), + invokeGatewayRoute(handler, { path: '/api/v1/research/workbench?hours=168&limit=20' }), ]); assert.equal(strategyDetail.statusCode, 200); diff --git a/apps/api/test/stage-3-baseline.test.ts b/apps/api/test/stage-3-baseline.test.ts index 1e07399b..1214c242 100644 --- a/apps/api/test/stage-3-baseline.test.ts +++ b/apps/api/test/stage-3-baseline.test.ts @@ -240,10 +240,10 @@ test('stage 3 baseline exposes execution workbench queues, linked incidents, and const { planId, incidentId } = seedExecutionChain('a'); const [workbench, ledger, brokerEvents] = await Promise.all([ - invokeGatewayRoute(handler, { path: '/api/execution/workbench' }), - invokeGatewayRoute(handler, { path: '/api/execution/ledger' }), + invokeGatewayRoute(handler, { path: '/api/v1/execution/workbench' }), + invokeGatewayRoute(handler, { path: '/api/v1/execution/ledger' }), invokeGatewayRoute(handler, { - path: `/api/execution/broker-events?executionPlanId=${planId}&limit=10`, + path: `/api/v1/execution/broker-events?executionPlanId=${planId}&limit=10`, }), ]); @@ -280,7 +280,7 @@ test('stage 3 baseline exposes execution detail, compensation and incident triag const { planId, incidentId } = seedExecutionChain('b'); const detail = await invokeGatewayRoute(handler, { - path: `/api/execution/plans/${planId}`, + path: `/api/v1/execution/plans/${planId}`, }); assert.equal(detail.statusCode, 200); @@ -294,7 +294,7 @@ test('stage 3 baseline exposes execution detail, compensation and incident triag const bulk = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/incidents/bulk', + path: '/api/v1/incidents/bulk', body: { incidentIds: [incidentId], owner: 'ops-stage3', @@ -311,7 +311,7 @@ test('stage 3 baseline exposes execution detail, compensation and incident triag assert.equal(bulk.json.notesAdded, 1); const incident = await invokeGatewayRoute(handler, { - path: `/api/incidents/${incidentId}`, + path: `/api/v1/incidents/${incidentId}`, }); assert.equal(incident.statusCode, 200); diff --git a/apps/api/test/stage-4-baseline.test.ts b/apps/api/test/stage-4-baseline.test.ts index 21ae0f02..4dbd5026 100644 --- a/apps/api/test/stage-4-baseline.test.ts +++ b/apps/api/test/stage-4-baseline.test.ts @@ -168,8 +168,8 @@ test('stage 4 baseline exposes stable risk and scheduler workbench contracts', a seedRiskSchedulerMiddleware(); const [riskWorkbench, schedulerWorkbench] = await Promise.all([ - invokeGatewayRoute(handler, { path: '/api/risk/workbench?hours=168&limit=8' }), - invokeGatewayRoute(handler, { path: '/api/scheduler/workbench?hours=168&limit=8' }), + invokeGatewayRoute(handler, { path: '/api/v1/risk/workbench?hours=168&limit=8' }), + invokeGatewayRoute(handler, { path: '/api/v1/scheduler/workbench?hours=168&limit=8' }), ]); assert.equal(riskWorkbench.statusCode, 200); @@ -195,7 +195,7 @@ test('stage 4 baseline exposes stable risk and scheduler action contracts', asyn const [riskAction, schedulerAction] = await Promise.all([ invokeGatewayRoute(handler, { method: 'POST', - path: '/api/risk/actions', + path: '/api/v1/risk/actions', body: { actionKey: 'release-emergency-brake', actor: 'stage4-risk-operator', @@ -205,7 +205,7 @@ test('stage 4 baseline exposes stable risk and scheduler action contracts', asyn }), invokeGatewayRoute(handler, { method: 'POST', - path: '/api/scheduler/actions', + path: '/api/v1/scheduler/actions', body: { actionKey: 'align-risk-window', actor: 'stage4-scheduler-operator', diff --git a/apps/api/test/stage-5-baseline.test.ts b/apps/api/test/stage-5-baseline.test.ts index 810f9d02..82ce3901 100644 --- a/apps/api/test/stage-5-baseline.test.ts +++ b/apps/api/test/stage-5-baseline.test.ts @@ -55,7 +55,7 @@ test('stage 5 baseline exposes agent sessions, intent, plan, and analysis run co // POST /api/agent/analysis-runs — full pipeline: intent → plan → analysis const analysisRes = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/analysis-runs', + path: '/api/v1/agent/analysis-runs', body: { prompt: 'Explain the current risk posture and suggest next steps.', requestedBy: 'stage5-operator', @@ -80,7 +80,7 @@ test('stage 5 baseline exposes agent sessions, intent, plan, and analysis run co // Session list exposes the newly created session const sessionListRes = await invokeGatewayRoute(handler, { - path: '/api/agent/sessions?limit=5', + path: '/api/v1/agent/sessions?limit=5', }); assert.equal(sessionListRes.statusCode, 200); @@ -93,7 +93,7 @@ test('stage 5 baseline exposes agent sessions, intent, plan, and analysis run co // Session detail exposes latest plan, analysis run, and message thread const detailRes = await invokeGatewayRoute(handler, { - path: `/api/agent/sessions/${sessionId}`, + path: `/api/v1/agent/sessions/${sessionId}`, }); assert.equal(detailRes.statusCode, 200); @@ -117,7 +117,7 @@ test('stage 5 baseline exposes agent sessions, intent, plan, and analysis run co test('stage 5 baseline exposes agent workbench collaboration queues', async () => { const analysisRes = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/agent/analysis-runs', + path: '/api/v1/agent/analysis-runs', body: { prompt: 'Explain the risk posture for ema-cross-us.', requestedBy: 'stage5-operator', @@ -141,7 +141,7 @@ test('stage 5 baseline exposes agent workbench collaboration queues', async () = }); const workbenchRes = await invokeGatewayRoute(handler, { - path: '/api/agent/workbench?hours=168&limit=10', + path: '/api/v1/agent/workbench?hours=168&limit=10', }); assert.equal(workbenchRes.statusCode, 200); @@ -228,7 +228,7 @@ test('stage 5 baseline exposes controlled action handoff from completed session' // Create controlled action request from the completed session const handoffRes = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/agent/sessions/${session.id}/action-requests`, + path: `/api/v1/agent/sessions/${session.id}/action-requests`, body: { requestedBy: 'stage5-operator' }, }); @@ -240,14 +240,14 @@ test('stage 5 baseline exposes controlled action handoff from completed session' // Action request appears in the list const actionListRes = await invokeGatewayRoute(handler, { - path: '/api/agent/action-requests', + path: '/api/v1/agent/action-requests', }); assert.equal(actionListRes.statusCode, 200); assert.equal(Array.isArray(actionListRes.json.requests), true); // Operator timeline for the session is accessible const timelineRes = await invokeGatewayRoute(handler, { - path: `/api/agent/sessions/${session.id}/timeline`, + path: `/api/v1/agent/sessions/${session.id}/timeline`, }); assert.equal(timelineRes.statusCode, 200); assert.equal(timelineRes.json.ok, true); @@ -269,7 +269,7 @@ test('stage 5 baseline exposes approval and rejection of agent action requests w const approveRes = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/agent/action-requests/${approveRequest.id}/approve`, + path: `/api/v1/agent/action-requests/${approveRequest.id}/approve`, body: { approvedBy: 'stage5-risk-operator', mode: 'paper', capital: 50000 }, }); @@ -292,7 +292,7 @@ test('stage 5 baseline exposes approval and rejection of agent action requests w const rejectRes = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/agent/action-requests/${rejectRequest.id}/reject`, + path: `/api/v1/agent/action-requests/${rejectRequest.id}/reject`, body: { rejectedBy: 'stage5-risk-operator', reason: 'Risk posture is not yet cleared.' }, }); @@ -323,7 +323,7 @@ test('stage 5 baseline enforces risk:review permission guardrail on action reque const unauthorizedApprove = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/agent/action-requests/${request.id}/approve`, + path: `/api/v1/agent/action-requests/${request.id}/approve`, body: { reviewedBy: 'unauthorized-user' }, }); @@ -333,7 +333,7 @@ test('stage 5 baseline enforces risk:review permission guardrail on action reque const unauthorizedReject = await invokeGatewayRoute(handler, { method: 'POST', - path: `/api/agent/action-requests/${request.id}/reject`, + path: `/api/v1/agent/action-requests/${request.id}/reject`, body: { reviewedBy: 'unauthorized-user', reason: 'No.' }, }); diff --git a/apps/api/test/stage-6-baseline.test.ts b/apps/api/test/stage-6-baseline.test.ts index e1786dd5..19c73903 100644 --- a/apps/api/test/stage-6-baseline.test.ts +++ b/apps/api/test/stage-6-baseline.test.ts @@ -99,9 +99,9 @@ test('stage 6 baseline exposes productionization posture across account scope an seedStage6ProductionizationState(); const [account, maintenance, monitoring] = await Promise.all([ - invokeGatewayRoute(handler, { path: '/api/user-account' }), - invokeGatewayRoute(handler, { path: '/api/operations/maintenance?limit=10' }), - invokeGatewayRoute(handler, { path: '/api/monitoring/status' }), + invokeGatewayRoute(handler, { path: '/api/v1/user-account' }), + invokeGatewayRoute(handler, { path: '/api/v1/operations/maintenance?limit=10' }), + invokeGatewayRoute(handler, { path: '/api/v1/monitoring/status' }), ]); assert.equal(account.statusCode, 200); @@ -127,10 +127,10 @@ test('stage 6 baseline exposes backup, restore dry-run, workflow repair, and obs seedStage6ProductionizationState(); const [workbench, backup] = await Promise.all([ - invokeGatewayRoute(handler, { path: '/api/operations/workbench?hours=48&limit=20' }), + invokeGatewayRoute(handler, { path: '/api/v1/operations/workbench?hours=48&limit=20' }), invokeGatewayRoute(handler, { method: 'POST', - path: '/api/operations/maintenance/backup', + path: '/api/v1/operations/maintenance/backup', }), ]); @@ -151,7 +151,7 @@ test('stage 6 baseline exposes backup, restore dry-run, workflow repair, and obs const restorePreview = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/operations/maintenance/restore', + path: '/api/v1/operations/maintenance/restore', body: { dryRun: true, backup: backup.json.backup, @@ -160,7 +160,7 @@ test('stage 6 baseline exposes backup, restore dry-run, workflow repair, and obs const repair = await invokeGatewayRoute(handler, { method: 'POST', - path: '/api/operations/maintenance/repair/workflows', + path: '/api/v1/operations/maintenance/repair/workflows', body: { worker: 'stage6-baseline-worker', limit: 10, diff --git a/apps/api/test/stage-7-agent-governance-baseline.test.ts b/apps/api/test/stage-7-agent-governance-baseline.test.ts index c9faf9b5..798413ad 100644 --- a/apps/api/test/stage-7-agent-governance-baseline.test.ts +++ b/apps/api/test/stage-7-agent-governance-baseline.test.ts @@ -58,7 +58,7 @@ test.after(() => { test('stage 7 baseline exposes agent governance contracts', async () => { const response = await invokeGateway(handler, { method: 'GET', - path: '/api/agent/workbench', + path: '/api/v1/agent/workbench', }); assert.equal(response.statusCode, 200); diff --git a/apps/api/test/stage-8-agent-daily-run.test.ts b/apps/api/test/stage-8-agent-daily-run.test.ts index 2a39650c..1c2f9065 100644 --- a/apps/api/test/stage-8-agent-daily-run.test.ts +++ b/apps/api/test/stage-8-agent-daily-run.test.ts @@ -54,7 +54,7 @@ test.after(() => { test('POST /api/agent/daily-runs queues a pre_market run', async () => { const response = await invokeGateway(handler, { method: 'POST', - path: '/api/agent/daily-runs', + path: '/api/v1/agent/daily-runs', body: { kind: 'pre_market', trigger: 'manual', requestedBy: 'operator' }, }); @@ -68,13 +68,13 @@ test('POST /api/agent/daily-runs queues a pre_market run', async () => { test('GET /api/agent/daily-runs returns runs list', async () => { await invokeGateway(handler, { method: 'POST', - path: '/api/agent/daily-runs', + path: '/api/v1/agent/daily-runs', body: { kind: 'intraday_monitor', trigger: 'manual', requestedBy: 'system' }, }); const response = await invokeGateway(handler, { method: 'GET', - path: '/api/agent/daily-runs', + path: '/api/v1/agent/daily-runs', }); assert.equal(response.statusCode, 200); @@ -92,7 +92,7 @@ test('POST /api/agent/daily-runs returns 403 without strategy:write permission', const response = await invokeGateway(handler, { method: 'POST', - path: '/api/agent/daily-runs', + path: '/api/v1/agent/daily-runs', body: { kind: 'pre_market', trigger: 'manual', requestedBy: 'operator' }, }); @@ -115,7 +115,7 @@ test('POST /api/agent/daily-runs returns 403 without strategy:write permission', test('POST /api/agent/action-requests accepts agent_trim request type', async () => { const response = await invokeGateway(handler, { method: 'POST', - path: '/api/agent/action-requests', + path: '/api/v1/agent/action-requests', body: { requestType: 'agent_trim', targetId: `strategy-${randomUUID()}`, @@ -133,7 +133,7 @@ test('POST /api/agent/action-requests accepts agent_trim request type', async () test('POST /api/agent/action-requests rejects unknown request type', async () => { const response = await invokeGateway(handler, { method: 'POST', - path: '/api/agent/action-requests', + path: '/api/v1/agent/action-requests', body: { requestType: 'agent_fly_to_moon', targetId: 'strategy-1', diff --git a/apps/web/package.json b/apps/web/package.json index c381aa95..0a01337e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "build": "vite build --config vite.config.ts", "preview": "vite preview --config vite.config.ts --host 0.0.0.0 --port 8080", "typecheck": "tsc --noEmit -p tsconfig.json", - "test:styles": "vitest run src/app/styles/style.test.ts --environment node" + "test:styles": "vitest run src/app/styles/style.test.ts --environment node", + "test:coverage": "vitest run --coverage" } } diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index 120ea02e..e1b7d438 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -1,10 +1,30 @@ +import { useState } from 'react'; +import { + isOnboardingComplete, + OnboardingWizard, +} from '../components/onboarding/OnboardingWizard.tsx'; +import { useLocale } from '../modules/console/console.i18n.tsx'; import { AppProviders } from './providers/AppProviders.tsx'; import { AppRouter } from './routes/AppRouter.tsx'; +function AppContent() { + const { locale } = useLocale(); + const [showOnboarding, setShowOnboarding] = useState(!isOnboardingComplete()); + + return ( + <> + + {showOnboarding && ( + setShowOnboarding(false)} /> + )} + + ); +} + export default function App() { return ( - + ); } diff --git a/apps/web/src/app/api/controlPlane.ts b/apps/web/src/app/api/controlPlane.ts index 56f89918..85f686e4 100644 --- a/apps/web/src/app/api/controlPlane.ts +++ b/apps/web/src/app/api/controlPlane.ts @@ -43,22 +43,22 @@ import type { export { ApiPermissionError } from './http.ts'; -import { assertOk, fetchJson, jsonHeaders } from './http.ts'; +import { API_PREFIX, assertOk, fetchJson, jsonHeaders } from './http.ts'; export async function fetchOperatorSession(): Promise { - return fetchJson('/api/auth/session', { + return fetchJson(`${API_PREFIX}/auth/session`, { headers: { Accept: 'application/json' }, }); } export async function fetchUserAccountProfile(): Promise { - return fetchJson('/api/user-account/profile', { + return fetchJson(`${API_PREFIX}/user-account/profile`, { headers: { Accept: 'application/json' }, }); } export async function fetchUserAccount(): Promise { - return fetchJson('/api/user-account', { + return fetchJson(`${API_PREFIX}/user-account`, { headers: { Accept: 'application/json' }, }); } @@ -66,7 +66,7 @@ export async function fetchUserAccount(): Promise { export async function updateUserAccountProfile( payload: Record ): Promise { - const response = await fetch('/api/user-account/profile', { + const response = await fetch(`${API_PREFIX}/user-account/profile`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -78,7 +78,7 @@ export async function updateUserAccountProfile( export async function updateUserAccountPreferences( payload: Record ): Promise { - const response = await fetch('/api/user-account/preferences', { + const response = await fetch(`${API_PREFIX}/user-account/preferences`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -90,7 +90,7 @@ export async function updateUserAccountPreferences( export async function updateUserAccountAccess( payload: Record ): Promise { - const response = await fetch('/api/user-account/access', { + const response = await fetch(`${API_PREFIX}/user-account/access`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -100,7 +100,7 @@ export async function updateUserAccountAccess( } export async function fetchBrokerBindings(): Promise { - const response = await fetch('/api/user-account/broker-bindings', { + const response = await fetch(`${API_PREFIX}/user-account/broker-bindings`, { headers: { Accept: 'application/json' }, }); await assertOk(response); @@ -110,7 +110,7 @@ export async function fetchBrokerBindings(): Promise export async function saveBrokerBinding( payload: Record ): Promise { - const response = await fetch('/api/user-account/broker-bindings', { + const response = await fetch(`${API_PREFIX}/user-account/broker-bindings`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -122,7 +122,7 @@ export async function saveBrokerBinding( export async function setDefaultBrokerBinding( bindingId: string ): Promise { - const response = await fetch(`/api/user-account/broker-bindings/${bindingId}/default`, { + const response = await fetch(`${API_PREFIX}/user-account/broker-bindings/${bindingId}/default`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify({}), @@ -134,7 +134,7 @@ export async function setDefaultBrokerBinding( export async function deleteBrokerBinding( bindingId: string ): Promise { - const response = await fetch(`/api/user-account/broker-bindings/${bindingId}`, { + const response = await fetch(`${API_PREFIX}/user-account/broker-bindings/${bindingId}`, { method: 'DELETE', headers: { Accept: 'application/json' }, }); @@ -143,7 +143,7 @@ export async function deleteBrokerBinding( } export async function fetchBrokerBindingRuntime(): Promise { - const response = await fetch('/api/user-account/broker-bindings/runtime', { + const response = await fetch(`${API_PREFIX}/user-account/broker-bindings/runtime`, { headers: { Accept: 'application/json' }, }); await assertOk(response); @@ -151,7 +151,7 @@ export async function fetchBrokerBindingRuntime(): Promise { - const response = await fetch('/api/user-account/broker-bindings/sync', { + const response = await fetch(`${API_PREFIX}/user-account/broker-bindings/sync`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify({}), @@ -177,7 +177,7 @@ function buildCyclePayload(state: TradingState): CycleRunPayload { } export async function runCycle(state: TradingState): Promise { - const response = await fetch('/api/task-orchestrator/cycles/run', { + const response = await fetch(`${API_PREFIX}/task-orchestrator/cycles/run`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(buildCyclePayload(state)), @@ -187,7 +187,7 @@ export async function runCycle(state: TradingState): Promise { - const response = await fetch('/api/task-orchestrator/state/run', { + const response = await fetch(`${API_PREFIX}/task-orchestrator/state/run`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify({ state }), @@ -203,7 +203,7 @@ export async function reportOperatorAction(payload: { symbol?: string; level?: string; }) { - const response = await fetch('/api/task-orchestrator/actions', { + const response = await fetch(`${API_PREFIX}/task-orchestrator/actions`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -250,7 +250,7 @@ export async function fetchNotifications(options: NotificationsQuery = {}): Prom createdAt: string; }>; }> { - return fetchJson(`/api/notification/events${buildNotificationsQuery(options)}`, { + return fetchJson(`${API_PREFIX}/notification/events${buildNotificationsQuery(options)}`, { headers: { Accept: 'application/json' }, }); } @@ -290,7 +290,7 @@ export async function fetchAuditRecords(options: AuditRecordsQuery = {}): Promis metadata?: Record; }>; }> { - return fetchJson(`/api/audit/records${buildAuditRecordsQuery(options)}`, { + return fetchJson(`${API_PREFIX}/audit/records${buildAuditRecordsQuery(options)}`, { headers: { Accept: 'application/json' }, }); } @@ -309,7 +309,7 @@ export async function fetchRiskEvents(): Promise<{ createdAt: string; }>; }> { - return fetchJson('/api/risk/events', { + return fetchJson(`${API_PREFIX}/risk/events`, { headers: { Accept: 'application/json' }, }); } @@ -325,7 +325,7 @@ export async function fetchRiskWorkbench( params.set('hours', String(options.hours)); } const query = params.toString(); - return fetchJson(`/api/risk/workbench${query ? `?${query}` : ''}`, { + return fetchJson(`${API_PREFIX}/risk/workbench${query ? `?${query}` : ''}`, { headers: { Accept: 'application/json' }, }); } @@ -336,7 +336,7 @@ export async function runRiskPolicyAction(payload: { hours?: number | null; limit?: number; }): Promise { - const response = await fetch('/api/risk/actions', { + const response = await fetch(`${API_PREFIX}/risk/actions`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -346,7 +346,7 @@ export async function runRiskPolicyAction(payload: { } export async function fetchRiskEventDetail(eventId: string): Promise { - return fetchJson(`/api/risk/events/${eventId}`, { + return fetchJson(`${API_PREFIX}/risk/events/${eventId}`, { headers: { Accept: 'application/json' }, }); } @@ -360,7 +360,7 @@ export type RiskParameters = { }; export async function fetchRiskParameters(): Promise<{ ok: boolean; parameters: RiskParameters }> { - return fetchJson('/api/risk/parameters', { + return fetchJson(`${API_PREFIX}/risk/parameters`, { headers: { Accept: 'application/json' }, }); } @@ -368,7 +368,7 @@ export async function fetchRiskParameters(): Promise<{ ok: boolean; parameters: export async function saveRiskParameters( patch: Partial ): Promise<{ ok: boolean; parameters: RiskParameters }> { - return fetchJson('/api/risk/parameters', { + return fetchJson(`${API_PREFIX}/risk/parameters`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(patch), @@ -379,7 +379,7 @@ export async function resetRiskParametersToDefaults(): Promise<{ ok: boolean; parameters: RiskParameters; }> { - return fetchJson('/api/risk/parameters/reset', { + return fetchJson(`${API_PREFIX}/risk/parameters/reset`, { method: 'POST', headers: jsonHeaders(), body: '{}', @@ -421,7 +421,7 @@ export async function fetchSchedulerTicks(options: SchedulerTicksQuery = {}): Pr createdAt: string; }>; }> { - return fetchJson(`/api/scheduler/ticks${buildSchedulerTicksQuery(options)}`, { + return fetchJson(`${API_PREFIX}/scheduler/ticks${buildSchedulerTicksQuery(options)}`, { headers: { Accept: 'application/json' }, }); } @@ -429,7 +429,7 @@ export async function fetchSchedulerTicks(options: SchedulerTicksQuery = {}): Pr export async function fetchSchedulerWorkbench( options: SchedulerTicksQuery = {} ): Promise { - return fetchJson(`/api/scheduler/workbench${buildSchedulerTicksQuery(options)}`, { + return fetchJson(`${API_PREFIX}/scheduler/workbench${buildSchedulerTicksQuery(options)}`, { headers: { Accept: 'application/json' }, }); } @@ -440,7 +440,7 @@ export async function runSchedulerOrchestrationAction(payload: { hours?: number | null; limit?: number; }): Promise { - const response = await fetch('/api/scheduler/actions', { + const response = await fetch(`${API_PREFIX}/scheduler/actions`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -485,13 +485,13 @@ export async function fetchOperatorActions(options: OperatorActionsQuery = {}): createdAt: string; }>; }> { - return fetchJson(`/api/task-orchestrator/actions${buildOperatorActionsQuery(options)}`, { + return fetchJson(`${API_PREFIX}/task-orchestrator/actions${buildOperatorActionsQuery(options)}`, { headers: { Accept: 'application/json' }, }); } export async function fetchTaskWorkflows(): Promise { - return fetchJson('/api/task-orchestrator/workflows', { + return fetchJson(`${API_PREFIX}/task-orchestrator/workflows`, { headers: { Accept: 'application/json' }, }); } @@ -499,7 +499,7 @@ export async function fetchTaskWorkflows(): Promise { export async function fetchWorkflowRunDetail( workflowRunId: string ): Promise { - return fetchJson(`/api/task-orchestrator/workflows/${workflowRunId}`, { + return fetchJson(`${API_PREFIX}/task-orchestrator/workflows/${workflowRunId}`, { headers: { Accept: 'application/json' }, }); } @@ -508,7 +508,7 @@ export async function fetchExecutionRuntime(): Promise<{ ok: boolean; events: ExecutionRuntimeEvent[]; }> { - return fetchJson('/api/execution/runtime', { + return fetchJson(`${API_PREFIX}/execution/runtime`, { headers: { Accept: 'application/json' }, }); } @@ -517,7 +517,7 @@ export async function fetchExecutionAccountSnapshots(): Promise<{ ok: boolean; snapshots: BrokerAccountSnapshotRecord[]; }> { - return fetchJson('/api/execution/account-snapshots', { + return fetchJson(`${API_PREFIX}/execution/account-snapshots`, { headers: { Accept: 'application/json' }, }); } @@ -526,19 +526,19 @@ export async function fetchExecutionLedger(): Promise<{ ok: boolean; entries: ExecutionLedgerEntry[]; }> { - return fetchJson('/api/execution/ledger', { + return fetchJson(`${API_PREFIX}/execution/ledger`, { headers: { Accept: 'application/json' }, }); } export async function fetchExecutionWorkbench(): Promise { - return fetchJson('/api/execution/workbench', { + return fetchJson(`${API_PREFIX}/execution/workbench`, { headers: { Accept: 'application/json' }, }); } export async function fetchExecutionCandidateHandoffs(): Promise { - return fetchJson('/api/research/execution-candidates', { + return fetchJson(`${API_PREFIX}/research/execution-candidates`, { headers: { Accept: 'application/json' }, }); } @@ -550,7 +550,7 @@ export async function queueExecutionCandidateHandoff( owner?: string; } = {} ) { - const response = await fetch(`/api/research/execution-candidates/${handoffId}/queue`, { + const response = await fetch(`${API_PREFIX}/research/execution-candidates/${handoffId}/queue`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -562,7 +562,7 @@ export async function queueExecutionCandidateHandoff( export async function fetchExecutionPlanDetail( planId: string ): Promise { - return fetchJson(`/api/execution/plans/${planId}`, { + return fetchJson(`${API_PREFIX}/execution/plans/${planId}`, { headers: { Accept: 'application/json' }, }); } @@ -573,7 +573,7 @@ export async function approveExecutionPlan( actor?: string; } = {} ) { - const response = await fetch(`/api/execution/plans/${planId}/approve`, { + const response = await fetch(`${API_PREFIX}/execution/plans/${planId}/approve`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -589,7 +589,7 @@ export async function settleExecutionPlan( outcome?: 'filled' | 'partial_fill' | 'cancelled' | 'failed'; } = {} ) { - const response = await fetch(`/api/execution/plans/${planId}/settle`, { + const response = await fetch(`${API_PREFIX}/execution/plans/${planId}/settle`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -605,7 +605,7 @@ export async function syncExecutionPlan( scenario?: 'acknowledge' | 'partial_fill' | 'filled' | 'failed'; } = {} ) { - const response = await fetch(`/api/execution/plans/${planId}/sync`, { + const response = await fetch(`${API_PREFIX}/execution/plans/${planId}/sync`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -629,7 +629,7 @@ export async function ingestBrokerExecutionEvent( reason?: string; } ) { - const response = await fetch(`/api/execution/plans/${planId}/broker-events`, { + const response = await fetch(`${API_PREFIX}/execution/plans/${planId}/broker-events`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -645,7 +645,7 @@ export async function cancelExecutionPlan( reason?: string; } = {} ) { - const response = await fetch(`/api/execution/plans/${planId}/cancel`, { + const response = await fetch(`${API_PREFIX}/execution/plans/${planId}/cancel`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -660,7 +660,7 @@ export async function reconcileExecutionPlan( actor?: string; } = {} ) { - const response = await fetch(`/api/execution/plans/${planId}/reconcile`, { + const response = await fetch(`${API_PREFIX}/execution/plans/${planId}/reconcile`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -675,7 +675,7 @@ export async function compensateExecutionPlan( actor?: string; } = {} ) { - const response = await fetch(`/api/execution/plans/${planId}/compensate`, { + const response = await fetch(`${API_PREFIX}/execution/plans/${planId}/compensate`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -690,7 +690,7 @@ export async function recoverExecutionPlan( actor?: string; } = {} ) { - const response = await fetch(`/api/execution/plans/${planId}/recover`, { + const response = await fetch(`${API_PREFIX}/execution/plans/${planId}/recover`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -704,7 +704,7 @@ export async function bulkUpdateExecutionQueue(payload: { planIds: string[]; actor?: string; }): Promise { - const response = await fetch('/api/execution/plans/bulk', { + const response = await fetch(`${API_PREFIX}/execution/plans/bulk`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -714,7 +714,7 @@ export async function bulkUpdateExecutionQueue(payload: { } export async function fetchLatestBrokerAccountSnapshot(): Promise { - return fetchJson('/api/execution/account-snapshots/latest', { + return fetchJson(`${API_PREFIX}/execution/account-snapshots/latest`, { headers: { Accept: 'application/json' }, }); } @@ -723,13 +723,13 @@ export async function fetchMarketProviderStatus(): Promise<{ ok: boolean; status: MarketProviderStatusSnapshot; }> { - return fetchJson('/api/market/provider-status', { + return fetchJson(`${API_PREFIX}/market/provider-status`, { headers: { Accept: 'application/json' }, }); } export async function fetchMonitoringStatus(): Promise { - return fetchJson('/api/monitoring/status', { + return fetchJson(`${API_PREFIX}/monitoring/status`, { headers: { Accept: 'application/json' }, }); } @@ -772,7 +772,7 @@ function buildMonitoringHistoryQuery(options: MonitoringHistoryQuery = {}) { export async function fetchMonitoringAlerts( options: MonitoringHistoryQuery = {} ): Promise { - return fetchJson(`/api/monitoring/alerts${buildMonitoringHistoryQuery(options)}`, { + return fetchJson(`${API_PREFIX}/monitoring/alerts${buildMonitoringHistoryQuery(options)}`, { headers: { Accept: 'application/json' }, }); } @@ -780,7 +780,7 @@ export async function fetchMonitoringAlerts( export async function fetchMonitoringSnapshots( options: MonitoringHistoryQuery = {} ): Promise { - return fetchJson(`/api/monitoring/snapshots${buildMonitoringHistoryQuery(options)}`, { + return fetchJson(`${API_PREFIX}/monitoring/snapshots${buildMonitoringHistoryQuery(options)}`, { headers: { Accept: 'application/json' }, }); } @@ -796,7 +796,7 @@ export async function fetchOperationsWorkbench( params.set('hours', String(options.hours)); } const query = params.toString(); - return fetchJson(`/api/operations/workbench${query ? `?${query}` : ''}`, { + return fetchJson(`${API_PREFIX}/operations/workbench${query ? `?${query}` : ''}`, { headers: { Accept: 'application/json' }, }); } @@ -809,7 +809,7 @@ export async function fetchOperationsMaintenance( params.set('limit', String(options.limit)); } const query = params.toString(); - return fetchJson(`/api/operations/maintenance${query ? `?${query}` : ''}`, { + return fetchJson(`${API_PREFIX}/operations/maintenance${query ? `?${query}` : ''}`, { headers: { Accept: 'application/json' }, }); } @@ -850,7 +850,7 @@ function buildIncidentsQuery(options: IncidentsQuery = {}) { } export async function fetchIncidents(options: IncidentsQuery = {}): Promise { - return fetchJson(`/api/incidents${buildIncidentsQuery(options)}`, { + return fetchJson(`${API_PREFIX}/incidents${buildIncidentsQuery(options)}`, { headers: { Accept: 'application/json' }, }); } @@ -858,7 +858,7 @@ export async function fetchIncidents(options: IncidentsQuery = {}): Promise { - return fetchJson(`/api/incidents/summary${buildIncidentsQuery(options)}`, { + return fetchJson(`${API_PREFIX}/incidents/summary${buildIncidentsQuery(options)}`, { headers: { Accept: 'application/json' }, }); } @@ -870,7 +870,7 @@ export async function fetchIncidentDetail( taskLimit = 100 ): Promise { return fetchJson( - `/api/incidents/${incidentId}?noteLimit=${noteLimit}&activityLimit=${activityLimit}&taskLimit=${taskLimit}`, + `${API_PREFIX}/incidents/${incidentId}?noteLimit=${noteLimit}&activityLimit=${activityLimit}&taskLimit=${taskLimit}`, { headers: { Accept: 'application/json' }, } @@ -880,7 +880,7 @@ export async function fetchIncidentDetail( export async function createIncident( payload: Record ): Promise<{ ok: boolean; incident: IncidentDetailResponse['incident'] }> { - const response = await fetch('/api/incidents', { + const response = await fetch(`${API_PREFIX}/incidents`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -893,7 +893,7 @@ export async function updateIncident( incidentId: string, payload: Record ): Promise<{ ok: boolean; incident: IncidentDetailResponse['incident'] }> { - const response = await fetch(`/api/incidents/${incidentId}`, { + const response = await fetch(`${API_PREFIX}/incidents/${incidentId}`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -905,7 +905,7 @@ export async function updateIncident( export async function bulkUpdateIncidentQueue( payload: Record ): Promise { - const response = await fetch('/api/incidents/bulk', { + const response = await fetch(`${API_PREFIX}/incidents/bulk`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -922,7 +922,7 @@ export async function appendIncidentNote( incident: IncidentDetailResponse['incident'] | null; note: IncidentDetailResponse['notes'][number]; }> { - const response = await fetch(`/api/incidents/${incidentId}/notes`, { + const response = await fetch(`${API_PREFIX}/incidents/${incidentId}/notes`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -938,7 +938,7 @@ export async function appendIncidentTask( ok: boolean; task: IncidentDetailResponse['tasks']['items'][number]; }> { - const response = await fetch(`/api/incidents/${incidentId}/tasks`, { + const response = await fetch(`${API_PREFIX}/incidents/${incidentId}/tasks`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -955,7 +955,7 @@ export async function updateIncidentTask( ok: boolean; task: IncidentDetailResponse['tasks']['items'][number]; }> { - const response = await fetch(`/api/incidents/${incidentId}/tasks/${taskId}`, { + const response = await fetch(`${API_PREFIX}/incidents/${incidentId}/tasks/${taskId}`, { method: 'POST', headers: jsonHeaders(), body: JSON.stringify(payload), @@ -970,7 +970,7 @@ export async function fetchOhlcv( limit = 100 ): Promise { const params = new URLSearchParams({ symbol, timeframe, limit: String(limit) }); - const response = await fetch(`/api/market/ohlcv?${params}`); + const response = await fetch(`${API_PREFIX}/market/ohlcv?${params}`); await assertOk(response); return response.json(); } diff --git a/apps/web/src/app/api/http.ts b/apps/web/src/app/api/http.ts index 32c31492..50de483b 100644 --- a/apps/web/src/app/api/http.ts +++ b/apps/web/src/app/api/http.ts @@ -1,3 +1,5 @@ +export const API_PREFIX = '/api/v1'; + export class ApiPermissionError extends Error { missingPermission?: string; permission?: { diff --git a/apps/web/src/app/config/runtime.ts b/apps/web/src/app/config/runtime.ts index 748a32cf..8b09d548 100644 --- a/apps/web/src/app/config/runtime.ts +++ b/apps/web/src/app/config/runtime.ts @@ -1,4 +1,5 @@ import type { RuntimeConfig } from '@shared-types/trading.ts'; +import { API_PREFIX } from '../api/http.ts'; function numberFromEnv(value: string | undefined, fallback: number): number { const parsed = Number(value); @@ -13,5 +14,5 @@ export const runtimeConfig: RuntimeConfig = { brokerProvider: (import.meta.env.VITE_BROKER_PROVIDER || 'simulated') as RuntimeConfig['brokerProvider'], brokerHttpUrl: import.meta.env.VITE_BROKER_HTTP_URL || '', - alpacaProxyBase: import.meta.env.VITE_ALPACA_PROXY_BASE || '/api/alpaca', + alpacaProxyBase: import.meta.env.VITE_ALPACA_PROXY_BASE || `${API_PREFIX}/alpaca`, }; diff --git a/apps/web/src/app/providers/AppProviders.tsx b/apps/web/src/app/providers/AppProviders.tsx index a53ebe80..ce32b4ee 100644 --- a/apps/web/src/app/providers/AppProviders.tsx +++ b/apps/web/src/app/providers/AppProviders.tsx @@ -1,11 +1,14 @@ import type { PropsWithChildren } from 'react'; import { BrowserRouter } from 'react-router-dom'; import { ToastProvider } from '../../components/toast/Toast.tsx'; +import { ThemeProvider } from '../../hooks/ThemeProvider.tsx'; export function AppProviders({ children }: PropsWithChildren) { return ( - {children} + + {children} + ); } diff --git a/apps/web/src/app/providers/broker.ts b/apps/web/src/app/providers/broker.ts index c531c27d..548c6338 100644 --- a/apps/web/src/app/providers/broker.ts +++ b/apps/web/src/app/providers/broker.ts @@ -4,11 +4,11 @@ import type { BrokerSnapshot, RuntimeConfig, } from '@shared-types/trading.ts'; -import { fetchJson, jsonHeaders } from '../api/http.ts'; +import { API_PREFIX, fetchJson, jsonHeaders } from '../api/http.ts'; function resolveBrowserBrokerBase(config: RuntimeConfig): string { if (config.brokerHttpUrl) return config.brokerHttpUrl.replace(/\/$/, ''); - return new URL('/api/broker', window.location.origin).toString(); + return new URL(`${API_PREFIX}/broker`, window.location.origin).toString(); } function simulatedBroker(): BrokerProvider { diff --git a/apps/web/src/app/providers/marketData.ts b/apps/web/src/app/providers/marketData.ts index b7435e03..b9271751 100644 --- a/apps/web/src/app/providers/marketData.ts +++ b/apps/web/src/app/providers/marketData.ts @@ -66,7 +66,7 @@ function customHttpProvider(config: RuntimeConfig): MarketDataProvider { } ); const rawQuotes = Array.isArray(payload?.data) ? payload.data : []; - const quotes = rawQuotes.map(normalizeQuote).filter(Boolean); + const quotes = rawQuotes.map(normalizeQuote).filter((q): q is Quote => q !== null); return { connected: true, fallback: false, @@ -104,7 +104,7 @@ function alpacaProvider(config: RuntimeConfig): MarketDataProvider { } ); const quotes = Array.isArray(payload?.quotes) - ? payload.quotes.map(normalizeQuote).filter(Boolean) + ? payload.quotes.map(normalizeQuote).filter((q): q is Quote => q !== null) : []; return { connected: true, diff --git a/apps/web/src/app/styles/panels.css.ts b/apps/web/src/app/styles/panels.css.ts index 867985ec..43025d85 100644 --- a/apps/web/src/app/styles/panels.css.ts +++ b/apps/web/src/app/styles/panels.css.ts @@ -562,6 +562,23 @@ globalStyle('.empty-state-detail', { maxWidth: '260px', } as any); +globalStyle('.empty-state-action', { + marginTop: '8px', + padding: '6px 16px', + background: 'var(--accent-subtle)', + border: '1px solid var(--accent)', + borderRadius: 'var(--radius)', + color: 'var(--accent)', + font: '600 12px/1 var(--font-ui)', + cursor: 'pointer', + transition: 'all 150ms ease', +} as any); + +globalStyle('.empty-state-action:hover', { + background: 'var(--accent)', + color: '#fff', +} as any); + /* ============================================================ TAB PANEL ============================================================ */ diff --git a/apps/web/src/app/styles/themes/dark.css.ts b/apps/web/src/app/styles/themes/dark.css.ts new file mode 100644 index 00000000..011ba910 --- /dev/null +++ b/apps/web/src/app/styles/themes/dark.css.ts @@ -0,0 +1,116 @@ +import { createTheme, createThemeContract } from '@vanilla-extract/css'; + +export const themeVars = createThemeContract({ + /* Canvas layers */ + bgCanvas: null, + bgPanel: null, + bgPanel2: null, + bgPanel3: null, + bgPanelFrame: null, + + /* Lines & borders */ + line: null, + lineStrong: null, + lineVivid: null, + + /* Typography */ + text: null, + textStrong: null, + muted: null, + mutedStrong: null, + + /* Accent */ + accent: null, + accentHover: null, + accentSubtle: null, + accentSecondary: null, + accentTertiary: null, + + /* Semantic */ + success: null, + successSubtle: null, + warning: null, + warningSubtle: null, + danger: null, + dangerSubtle: null, + info: null, + infoSubtle: null, + + /* Trading signals */ + buy: null, + sell: null, + hold: null, + + /* Shadows */ + shadowSm: null, + shadowMd: null, + shadowLg: null, + shadowXl: null, + + /* Glows */ + glowAccent: null, + glowGreen: null, + glowAmber: null, + glowRed: null, + + /* Background image */ + bgOverlay: null, +}); + +export const darkTheme = createTheme(themeVars, { + /* Canvas layers */ + bgCanvas: '#05071a', + bgPanel: '#0c0f28', + bgPanel2: '#101430', + bgPanel3: '#151a3a', + bgPanelFrame: 'rgba(99, 102, 241, 0.10)', + + /* Lines & borders */ + line: 'rgba(99, 102, 241, 0.18)', + lineStrong: 'rgba(99, 102, 241, 0.35)', + lineVivid: 'rgba(99, 102, 241, 0.55)', + + /* Typography */ + text: '#e2e4f3', + textStrong: '#f4f5ff', + muted: 'rgba(160, 162, 210, 0.82)', + mutedStrong: 'rgba(190, 192, 235, 0.95)', + + /* Accent */ + accent: '#6366f1', + accentHover: '#4f46e5', + accentSubtle: 'rgba(99, 102, 241, 0.12)', + accentSecondary: '#ffb700', + accentTertiary: '#8b5cf6', + + /* Semantic */ + success: '#00e89d', + successSubtle: 'rgba(0, 232, 157, 0.12)', + warning: '#ffb700', + warningSubtle: 'rgba(255, 183, 0, 0.12)', + danger: '#ff3358', + dangerSubtle: 'rgba(255, 51, 88, 0.12)', + info: '#6366f1', + infoSubtle: 'rgba(99, 102, 241, 0.12)', + + /* Trading signals */ + buy: '#00e89d', + sell: '#ff3358', + hold: '#ffb700', + + /* Shadows */ + shadowSm: '0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2)', + shadowMd: '0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3)', + shadowLg: '0 8px 28px rgba(0, 0, 0, 0.5)', + shadowXl: '0 24px 64px rgba(0, 0, 0, 0.75), 0 4px 16px rgba(0, 0, 0, 0.55)', + + /* Glows */ + glowAccent: '0 0 14px rgba(99, 102, 241, 0.55)', + glowGreen: '0 0 12px rgba(0, 232, 157, 0.55)', + glowAmber: '0 0 12px rgba(255, 183, 0, 0.55)', + glowRed: '0 0 12px rgba(255, 51, 88, 0.55)', + + /* Background image (dark-specific overlay) */ + bgOverlay: + 'radial-gradient(ellipse 130% 65% at 50% -5%, rgba(0, 100, 220, 0.20) 0%, transparent 65%), radial-gradient(ellipse 55% 45% at 90% 20%, rgba(99, 102, 241, 0.10) 0%, transparent 55%), radial-gradient(ellipse 45% 35% at 10% 80%, rgba(139, 92, 246, 0.10) 0%, transparent 55%)', +}); diff --git a/apps/web/src/app/styles/themes/light.css.ts b/apps/web/src/app/styles/themes/light.css.ts new file mode 100644 index 00000000..e7c12566 --- /dev/null +++ b/apps/web/src/app/styles/themes/light.css.ts @@ -0,0 +1,60 @@ +import { createTheme } from '@vanilla-extract/css'; +import { themeVars } from './dark.css.js'; + +export const lightTheme = createTheme(themeVars, { + /* Canvas layers */ + bgCanvas: '#f8f9fc', + bgPanel: '#ffffff', + bgPanel2: '#f1f3f9', + bgPanel3: '#e8ebf4', + bgPanelFrame: 'rgba(99, 102, 241, 0.15)', + + /* Lines & borders */ + line: 'rgba(99, 102, 241, 0.15)', + lineStrong: 'rgba(99, 102, 241, 0.30)', + lineVivid: 'rgba(99, 102, 241, 0.50)', + + /* Typography */ + text: '#1e1e2e', + textStrong: '#0f0f1a', + muted: 'rgba(60, 60, 90, 0.72)', + mutedStrong: 'rgba(40, 40, 70, 0.88)', + + /* Accent */ + accent: '#4f46e5', + accentHover: '#4338ca', + accentSubtle: 'rgba(79, 70, 229, 0.08)', + accentSecondary: '#d97706', + accentTertiary: '#7c3aed', + + /* Semantic */ + success: '#059669', + successSubtle: 'rgba(5, 150, 105, 0.08)', + warning: '#d97706', + warningSubtle: 'rgba(217, 119, 6, 0.08)', + danger: '#dc2626', + dangerSubtle: 'rgba(220, 38, 38, 0.08)', + info: '#4f46e5', + infoSubtle: 'rgba(79, 70, 229, 0.08)', + + /* Trading signals */ + buy: '#059669', + sell: '#dc2626', + hold: '#d97706', + + /* Shadows */ + shadowSm: '0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06)', + shadowMd: '0 4px 12px rgba(0, 0, 0, 0.10), 0 2px 4px rgba(0, 0, 0, 0.06)', + shadowLg: '0 8px 28px rgba(0, 0, 0, 0.12)', + shadowXl: '0 24px 64px rgba(0, 0, 0, 0.18), 0 4px 16px rgba(0, 0, 0, 0.10)', + + /* Glows */ + glowAccent: '0 0 14px rgba(79, 70, 229, 0.30)', + glowGreen: '0 0 12px rgba(5, 150, 105, 0.30)', + glowAmber: '0 0 12px rgba(217, 119, 6, 0.30)', + glowRed: '0 0 12px rgba(220, 38, 38, 0.30)', + + /* Background image (light: minimal) */ + bgOverlay: + 'radial-gradient(ellipse 130% 65% at 50% -5%, rgba(79, 70, 229, 0.06) 0%, transparent 65%)', +}); diff --git a/apps/web/src/components/business/ConsoleTables.tsx b/apps/web/src/components/business/ConsoleTables.tsx index 9375a8fc..4fcdf3c4 100644 --- a/apps/web/src/components/business/ConsoleTables.tsx +++ b/apps/web/src/components/business/ConsoleTables.tsx @@ -1,4 +1,5 @@ import type { BrokerOrder, BrokerPositionSnapshot } from '@shared-types/trading.ts'; +import { PnLAnimator, PriceFlash, SignalAlert } from '../../components/common/index.ts'; import { copy, useLocale } from '../../modules/console/console.i18n.tsx'; import { fmtCurrency, @@ -44,12 +45,27 @@ export function UniverseTable() { {stock.name} - {stock.price.toFixed(2)} + + + = 0 ? 'text-up' : 'text-down'}>{fmtPct(pct)} {stock.score.toFixed(1)} - - {translateSignal(locale, stock.signal)} + + + + {translateSignal(locale, stock.signal)} + {translateActionText(locale, stock.actionText)} @@ -90,9 +106,15 @@ export function PositionsTable({ accountKey }: { accountKey: 'paper' | 'live' }) {row.shares} - {row.avgCost.toFixed(2)} - {fmtCurrency(row.marketValue)} - = 0 ? 'text-up' : 'text-down'}>{fmtCurrency(row.pnl)} + + + + + + + + + )) ) : ( diff --git a/apps/web/src/components/charts/CandlestickChart.tsx b/apps/web/src/components/charts/CandlestickChart.tsx index 55f45bc9..bce05c8f 100644 --- a/apps/web/src/components/charts/CandlestickChart.tsx +++ b/apps/web/src/components/charts/CandlestickChart.tsx @@ -1,18 +1,95 @@ import type { OhlcvBar } from '@shared-types/trading.ts'; -import { CandlestickSeries, createChart, HistogramSeries } from 'lightweight-charts'; +import { CandlestickSeries, createChart, HistogramSeries, LineSeries } from 'lightweight-charts'; import { useEffect, useRef } from 'react'; +type ChartInstance = ReturnType; +type SeriesInstance = ReturnType; + +export type IndicatorConfig = { + sma?: number[]; + ema?: number[]; + bollinger?: { period: number; stdDev: number }; +}; + type Props = { data: OhlcvBar[]; timeframe?: string; + indicators?: IndicatorConfig; }; -export function CandlestickChart({ data, timeframe }: Props) { +function calcSMA(closes: number[], period: number): (number | null)[] { + const result: (number | null)[] = []; + for (let i = 0; i < closes.length; i++) { + if (i < period - 1) { + result.push(null); + } else { + let sum = 0; + for (let j = i - period + 1; j <= i; j++) sum += closes[j]; + result.push(sum / period); + } + } + return result; +} + +function calcEMA(closes: number[], period: number): (number | null)[] { + const result: (number | null)[] = []; + const k = 2 / (period + 1); + for (let i = 0; i < closes.length; i++) { + if (i < period - 1) { + result.push(null); + } else if (i === period - 1) { + let sum = 0; + for (let j = 0; j < period; j++) sum += closes[j]; + result.push(sum / period); + } else { + result.push(closes[i] * k + (result[i - 1] as number) * (1 - k)); + } + } + return result; +} + +function calcBollinger( + closes: number[], + period: number, + stdDev: number +): { upper: (number | null)[]; middle: (number | null)[]; lower: (number | null)[] } { + const middle = calcSMA(closes, period); + const upper: (number | null)[] = []; + const lower: (number | null)[] = []; + for (let i = 0; i < closes.length; i++) { + if (middle[i] === null) { + upper.push(null); + lower.push(null); + } else { + let variance = 0; + for (let j = i - period + 1; j <= i; j++) { + variance += (closes[j] - (middle[i] as number)) ** 2; + } + const sd = Math.sqrt(variance / period); + upper.push((middle[i] as number) + sd * stdDev); + lower.push((middle[i] as number) - sd * stdDev); + } + } + return { upper, middle, lower }; +} + +const INDICATOR_COLORS: Record = { + sma20: '#ffb700', + sma50: '#8b5cf6', + sma200: '#ef5350', + ema12: '#00e89d', + ema26: '#6366f1', + bb_upper: 'rgba(99, 102, 241, 0.5)', + bb_middle: 'rgba(99, 102, 241, 0.7)', + bb_lower: 'rgba(99, 102, 241, 0.5)', +}; + +export function CandlestickChart({ data, timeframe, indicators }: Props) { const containerRef = useRef(null); - // Keep refs so we can update data without re-creating the chart - const chartRef = useRef | null>(null); - const candleRef = useRef | null>(null); - const volumeRef = useRef | null>(null); + const chartRef = useRef(null); + const candleRef = useRef(null); + const volumeRef = useRef(null); + const indicatorRefs = useRef([]); useEffect(() => { const el = containerRef.current; @@ -81,12 +158,21 @@ export function CandlestickChart({ data, timeframe }: Props) { chartRef.current = null; candleRef.current = null; volumeRef.current = null; + indicatorRefs.current = []; }; }, []); - // Update data when it changes + // Update data and indicators when they change useEffect(() => { - if (!candleRef.current || !volumeRef.current || !data.length) return; + if (!candleRef.current || !volumeRef.current || !data.length || !chartRef.current) return; + + const chart = chartRef.current; + + // Remove old indicator series + for (const s of indicatorRefs.current) { + chart.removeSeries(s); + } + indicatorRefs.current = []; const candleData = data.map((b) => ({ time: b.time as unknown as string, @@ -104,8 +190,78 @@ export function CandlestickChart({ data, timeframe }: Props) { candleRef.current.setData(candleData); volumeRef.current.setData(volumeData); - chartRef.current?.timeScale().fitContent(); - }, [data]); + + const times = data.map((b) => b.time as unknown as string); + const closes = data.map((b) => b.close); + + // SMA indicators + if (indicators?.sma) { + for (const period of indicators.sma) { + const values = calcSMA(closes, period); + const series = chart.addSeries(LineSeries, { + color: INDICATOR_COLORS[`sma${period}`] ?? '#ffb700', + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: false, + title: `SMA ${period}`, + }); + series.setData( + values + .map((v, i) => (v !== null ? { time: times[i], value: v } : null)) + .filter(Boolean) as { time: string; value: number }[] + ); + indicatorRefs.current.push(series); + } + } + + // EMA indicators + if (indicators?.ema) { + for (const period of indicators.ema) { + const values = calcEMA(closes, period); + const series = chart.addSeries(LineSeries, { + color: INDICATOR_COLORS[`ema${period}`] ?? '#00e89d', + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: false, + title: `EMA ${period}`, + }); + series.setData( + values + .map((v, i) => (v !== null ? { time: times[i], value: v } : null)) + .filter(Boolean) as { time: string; value: number }[] + ); + indicatorRefs.current.push(series); + } + } + + // Bollinger Bands + if (indicators?.bollinger) { + const { period, stdDev } = indicators.bollinger; + const bb = calcBollinger(closes, period, stdDev); + for (const [key, values] of [ + ['upper', bb.upper], + ['middle', bb.middle], + ['lower', bb.lower], + ] as const) { + const series = chart.addSeries(LineSeries, { + color: INDICATOR_COLORS[`bb_${key}`], + lineWidth: 1, + lineStyle: key === 'middle' ? 0 : 2, + priceLineVisible: false, + lastValueVisible: false, + title: key === 'middle' ? `BB ${period}` : '', + }); + series.setData( + values + .map((v, i) => (v !== null ? { time: times[i], value: v } : null)) + .filter(Boolean) as { time: string; value: number }[] + ); + indicatorRefs.current.push(series); + } + } + + chart.timeScale().fitContent(); + }, [data, indicators]); // Fit content when timeframe changes useEffect(() => { diff --git a/apps/web/src/components/charts/DepthChart.tsx b/apps/web/src/components/charts/DepthChart.tsx new file mode 100644 index 00000000..cdd772df --- /dev/null +++ b/apps/web/src/components/charts/DepthChart.tsx @@ -0,0 +1,135 @@ +import { AreaSeries, createChart, LineSeries } from 'lightweight-charts'; +import { useEffect, useRef } from 'react'; + +export type DepthLevel = { + price: number; + cumulativeQty: number; +}; + +type Props = { + bids: DepthLevel[]; + asks: DepthLevel[]; + midPrice?: number; +}; + +export function DepthChart({ bids, asks, midPrice }: Props) { + const containerRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const chart = createChart(el, { + layout: { + background: { color: 'transparent' }, + textColor: 'rgba(160, 162, 210, 0.65)', + fontFamily: '"JetBrains Mono", monospace', + fontSize: 10, + }, + grid: { + vertLines: { color: 'rgba(99, 102, 241, 0.05)', style: 1 }, + horzLines: { color: 'rgba(99, 102, 241, 0.07)' }, + }, + rightPriceScale: { + borderColor: 'rgba(99, 102, 241, 0.12)', + textColor: 'rgba(160, 162, 210, 0.65)', + }, + timeScale: { + borderColor: 'rgba(99, 102, 241, 0.12)', + timeVisible: false, + }, + crosshair: { + vertLine: { color: 'rgba(99, 102, 241, 0.40)' }, + horzLine: { color: 'rgba(99, 102, 241, 0.40)' }, + }, + handleScroll: false, + handleScale: false, + }); + + // Bids (green area, left side) + const bidSeries = chart.addSeries(AreaSeries, { + lineColor: '#00e89d', + topColor: 'rgba(0, 232, 157, 0.25)', + bottomColor: 'rgba(0, 232, 157, 0)', + lineWidth: 2, + priceLineVisible: false, + lastValueVisible: false, + title: 'Bids', + }); + + // Asks (red area, right side) + const askSeries = chart.addSeries(AreaSeries, { + lineColor: '#ff3358', + topColor: 'rgba(255, 51, 88, 0.25)', + bottomColor: 'rgba(255, 51, 88, 0)', + lineWidth: 2, + priceLineVisible: false, + lastValueVisible: false, + title: 'Asks', + }); + + // Mid price line + if (midPrice) { + const midSeries = chart.addSeries(LineSeries, { + color: 'rgba(99, 102, 241, 0.6)', + lineWidth: 1, + lineStyle: 2, + priceLineVisible: false, + lastValueVisible: false, + }); + // Use bid/ask range to place the mid price marker + const allPrices = [...bids.map((b) => b.price), ...asks.map((a) => a.price)]; + if (allPrices.length > 0) { + const minP = Math.min(...allPrices); + const maxP = Math.max(...allPrices); + const maxQty = Math.max( + ...bids.map((b) => b.cumulativeQty), + ...asks.map((a) => a.cumulativeQty) + ); + midSeries.setData([ + { time: minP as unknown as string, value: 0 }, + { time: midPrice as unknown as string, value: maxQty }, + { time: maxP as unknown as string, value: 0 }, + ]); + } + } + + // Bids: sort descending by price (highest bid first) + const sortedBids = [...bids].sort((a, b) => b.price - a.price); + if (sortedBids.length) { + bidSeries.setData( + sortedBids.map((b) => ({ + time: b.price as unknown as string, + value: b.cumulativeQty, + })) + ); + } + + // Asks: sort ascending by price (lowest ask first) + const sortedAsks = [...asks].sort((a, b) => a.price - b.price); + if (sortedAsks.length) { + askSeries.setData( + sortedAsks.map((a) => ({ + time: a.price as unknown as string, + value: a.cumulativeQty, + })) + ); + } + + chart.timeScale().fitContent(); + + const ro = new ResizeObserver(() => { + chart.applyOptions({ width: el.clientWidth }); + }); + ro.observe(el); + + return () => { + ro.disconnect(); + chart.remove(); + }; + }, [bids, asks, midPrice]); + + return ( +
+ ); +} diff --git a/apps/web/src/components/charts/EquityChart.tsx b/apps/web/src/components/charts/EquityChart.tsx index f00c65a2..a538aaf6 100644 --- a/apps/web/src/components/charts/EquityChart.tsx +++ b/apps/web/src/components/charts/EquityChart.tsx @@ -1,4 +1,4 @@ -import { AreaSeries, createChart } from 'lightweight-charts'; +import { AreaSeries, createChart, HistogramSeries, LineSeries } from 'lightweight-charts'; import { useEffect, useRef } from 'react'; export type EquityPoint = { @@ -9,9 +9,20 @@ export type EquityPoint = { type Props = { paper: EquityPoint[]; live: EquityPoint[]; + showDrawdown?: boolean; + benchmark?: EquityPoint[]; }; -export function EquityChart({ paper, live }: Props) { +function calcDrawdown(points: EquityPoint[]): { value: number; label: string }[] { + let peak = -Infinity; + return points.map((p) => { + peak = Math.max(peak, p.value); + const dd = peak > 0 ? ((p.value - peak) / peak) * 100 : 0; + return { value: dd, label: p.label }; + }); +} + +export function EquityChart({ paper, live, showDrawdown = false, benchmark }: Props) { const containerRef = useRef(null); useEffect(() => { @@ -65,14 +76,47 @@ export function EquityChart({ paper, live }: Props) { title: 'Live', }); - // Convert equity points to lightweight-charts time series format - // Use sequential integer timestamps since labels may not be ISO dates const toData = (points: EquityPoint[]) => points.map((p, i) => ({ time: (i + 1) as unknown as string, value: p.value })); if (paper.length) paperSeries.setData(toData(paper)); if (live.length) liveSeries.setData(toData(live)); + // Benchmark line + if (benchmark?.length) { + const benchSeries = chart.addSeries(LineSeries, { + color: 'rgba(160, 162, 210, 0.4)', + lineWidth: 1, + lineStyle: 2, + priceLineVisible: false, + lastValueVisible: false, + title: 'Benchmark', + }); + benchSeries.setData(toData(benchmark)); + } + + // Drawdown overlay + if (showDrawdown && paper.length) { + const ddData = calcDrawdown(paper); + const ddSeries = chart.addSeries(HistogramSeries, { + color: 'rgba(255, 51, 88, 0.25)', + priceScaleId: 'dd', + priceLineVisible: false, + lastValueVisible: false, + title: 'Drawdown', + }); + ddSeries.setData( + ddData.map((d, i) => ({ + time: (i + 1) as unknown as string, + value: d.value, + color: d.value < -10 ? 'rgba(255, 51, 88, 0.4)' : 'rgba(255, 51, 88, 0.2)', + })) + ); + chart.priceScale('dd').applyOptions({ + scaleMargins: { top: 0.85, bottom: 0 }, + }); + } + chart.timeScale().fitContent(); const ro = new ResizeObserver(() => { @@ -84,7 +128,7 @@ export function EquityChart({ paper, live }: Props) { ro.disconnect(); chart.remove(); }; - }, [paper, live]); + }, [paper, live, showDrawdown, benchmark]); return (
diff --git a/apps/web/src/components/command-palette/CommandPalette.css.ts b/apps/web/src/components/command-palette/CommandPalette.css.ts index 82caeab2..781bbca3 100644 --- a/apps/web/src/components/command-palette/CommandPalette.css.ts +++ b/apps/web/src/components/command-palette/CommandPalette.css.ts @@ -154,6 +154,18 @@ export const resultEnter = style({ userSelect: 'none', }); +export const recentBadge = style({ + flexShrink: 0, + font: '600 9px/1 var(--font-data)', + letterSpacing: '0.06em', + textTransform: 'uppercase', + color: 'rgba(99, 102, 241, 0.6)', + background: 'rgba(99, 102, 241, 0.08)', + borderRadius: 'var(--radius-sm)', + padding: '3px 6px', + userSelect: 'none', +}); + export const emptyState = style({ padding: '32px 20px', textAlign: 'center', diff --git a/apps/web/src/components/command-palette/CommandPalette.tsx b/apps/web/src/components/command-palette/CommandPalette.tsx index eab85b1a..fa64f1cb 100644 --- a/apps/web/src/components/command-palette/CommandPalette.tsx +++ b/apps/web/src/components/command-palette/CommandPalette.tsx @@ -14,7 +14,9 @@ import { kbdHint, overlay, panel, + recentBadge, resultEnter, + resultHint, resultIcon, resultItem, resultItemActive, @@ -31,10 +33,31 @@ type CommandItem = { label: string; hint: string; icon: string; + category: 'page' | 'action' | 'symbol'; path?: string; action?: () => void; }; +/* ── Recent history ──────────────────────────────────── */ + +const RECENT_KEY = 'qp-cmd-recent'; +const MAX_RECENT = 5; + +function loadRecent(): string[] { + try { + const raw = localStorage.getItem(RECENT_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function saveRecent(id: string) { + const recent = loadRecent().filter((r) => r !== id); + recent.unshift(id); + localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, MAX_RECENT))); +} + /* ── Icon map ─────────────────────────────────────────── */ const ICONS: Record = { @@ -78,16 +101,32 @@ const HINTS_EN: Record = { /* ── Scoring ──────────────────────────────────────────── */ -function score(label: string, hint: string, query: string): number { +function fuzzyMatch(text: string, query: string): number { + const t = text.toLowerCase(); const q = query.toLowerCase(); - const l = label.toLowerCase(); - const h = hint.toLowerCase(); - if (l.startsWith(q)) return 3; - if (l.includes(q)) return 2; - if (h.includes(q)) return 1; + + // Exact prefix match + if (t.startsWith(q)) return 4; + // Word boundary match (e.g., "bk" matches "Backtest") + const words = t.split(/[\s-/]+/); + if (words.some((w) => w.startsWith(q))) return 3; + // Contains match + if (t.includes(q)) return 2; + // Fuzzy: all query chars appear in order + let qi = 0; + for (let ti = 0; ti < t.length && qi < q.length; ti++) { + if (t[ti] === q[qi]) qi++; + } + if (qi === q.length) return 1; return 0; } +function score(label: string, hint: string, query: string): number { + const labelScore = fuzzyMatch(label, query); + if (labelScore > 0) return labelScore + 2; // Prefer label matches + return fuzzyMatch(hint, query); +} + /* ── Component ────────────────────────────────────────── */ type Props = { @@ -111,16 +150,24 @@ export function CommandPalette({ locale, onClose }: Props) { label: navCopy[r.id] ?? r.id, hint: hints[r.id] ?? '', icon: ICONS[r.id] ?? r.id.slice(0, 2).toUpperCase(), + category: 'page' as const, path: r.path, })); + const recentIds = loadRecent(); + const recentItems = recentIds + .map((id) => allItems.find((item) => item.id === id)) + .filter(Boolean) as CommandItem[]; + const filtered = query.trim() ? allItems .map((item) => ({ item, s: score(item.label, item.hint, query) })) .filter(({ s }) => s > 0) .sort((a, b) => b.s - a.s) .map(({ item }) => item) - : allItems; + : recentItems.length > 0 + ? [...recentItems, ...allItems.filter((item) => !recentIds.includes(item.id))] + : allItems; // Reset active index when filtered list changes useEffect(() => { @@ -134,6 +181,7 @@ export function CommandPalette({ locale, onClose }: Props) { const commit = useCallback( (item: CommandItem) => { + saveRecent(item.id); if (item.path) navigate(item.path); if (item.action) item.action(); onClose(); @@ -184,24 +232,52 @@ export function CommandPalette({ locale, onClose }: Props) {
{filtered.length > 0 ? ( <> -
{locale === 'zh' ? '页面' : 'Pages'}
- {filtered.map((item, idx) => ( - - ))} + {!query.trim() && recentItems.length > 0 && ( + <> +
{locale === 'zh' ? '最近访问' : 'Recent'}
+ {recentItems.map((item, idx) => ( + + ))} +
{locale === 'zh' ? '全部页面' : 'All Pages'}
+ + )} + {(query.trim() ? filtered : filtered.slice(recentItems.length)).map((item, idx) => { + const adjustedIdx = query.trim() ? idx : idx + recentItems.length; + return ( + + ); + })} ) : (
diff --git a/apps/web/src/components/common/EmptyStates.tsx b/apps/web/src/components/common/EmptyStates.tsx new file mode 100644 index 00000000..9ea4e786 --- /dev/null +++ b/apps/web/src/components/common/EmptyStates.tsx @@ -0,0 +1,96 @@ +import { EmptyState } from '../layout/ConsoleChrome.tsx'; + +interface ContextualEmptyProps { + locale?: 'zh' | 'en'; + onAction?: () => void; +} + +export function EmptyStrategies({ locale = 'en', onAction }: ContextualEmptyProps) { + return ( + + ); +} + +export function EmptyPositions({ locale = 'en' }: ContextualEmptyProps) { + return ( + + ); +} + +export function EmptySignals({ locale = 'en' }: ContextualEmptyProps) { + return ( + + ); +} + +export function EmptyBacktests({ locale = 'en', onAction }: ContextualEmptyProps) { + return ( + + ); +} + +export function EmptyOrders({ locale = 'en' }: ContextualEmptyProps) { + return ( + + ); +} + +export function EmptyWatchlist({ locale = 'en', onAction }: ContextualEmptyProps) { + return ( + + ); +} diff --git a/apps/web/src/components/common/ErrorBanner.css.ts b/apps/web/src/components/common/ErrorBanner.css.ts new file mode 100644 index 00000000..c6a84a16 --- /dev/null +++ b/apps/web/src/components/common/ErrorBanner.css.ts @@ -0,0 +1,77 @@ +import { style } from '@vanilla-extract/css'; + +export const banner = style({ + display: 'flex', + alignItems: 'center', + gap: '10px', + padding: '10px 14px', + background: 'var(--danger-subtle, rgba(255, 51, 88, 0.12))', + border: '1px solid var(--danger, #ff3358)', + borderRadius: 'var(--radius)', + color: 'var(--danger, #ff3358)', + fontSize: '13px', + lineHeight: 1.4, + fontFamily: 'var(--font-ui)', +}); + +export const icon = style({ + flexShrink: 0, + fontSize: '16px', + lineHeight: 1, +}); + +export const content = style({ + flex: 1, + minWidth: 0, +}); + +export const message = style({ + fontWeight: 600, +}); + +export const detail = style({ + marginTop: '2px', + fontSize: '12px', + opacity: 0.8, +}); + +export const actions = style({ + display: 'flex', + gap: '6px', + flexShrink: 0, +}); + +export const btn = style({ + padding: '4px 10px', + background: 'transparent', + border: '1px solid currentColor', + borderRadius: 'var(--radius)', + color: 'inherit', + fontSize: '11px', + fontWeight: 600, + cursor: 'pointer', + transition: 'all 150ms ease', + selectors: { + '&:hover': { + background: 'currentColor', + color: '#fff', + }, + }, +}); + +export const dismissBtn = style({ + padding: '4px 8px', + background: 'transparent', + border: 'none', + color: 'inherit', + opacity: 0.6, + cursor: 'pointer', + fontSize: '14px', + lineHeight: 1, + transition: 'opacity 150ms ease', + selectors: { + '&:hover': { + opacity: 1, + }, + }, +}); diff --git a/apps/web/src/components/common/ErrorBanner.tsx b/apps/web/src/components/common/ErrorBanner.tsx new file mode 100644 index 00000000..44871668 --- /dev/null +++ b/apps/web/src/components/common/ErrorBanner.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import { + actions, + banner, + btn, + content, + detail, + dismissBtn, + icon, + message, +} from './ErrorBanner.css.js'; + +interface ErrorBannerProps { + title: string; + detail?: string; + onRetry?: () => void; + onDismiss?: () => void; + retryLabel?: string; + className?: string; +} + +export function ErrorBanner({ + title, + detail: detailText, + onRetry, + onDismiss, + retryLabel = 'Retry', + className = '', +}: ErrorBannerProps) { + const [dismissed, setDismissed] = useState(false); + + if (dismissed) return null; + + const handleDismiss = () => { + setDismissed(true); + onDismiss?.(); + }; + + return ( +
+ +
+
{title}
+ {detailText ?
{detailText}
: null} +
+
+ {onRetry ? ( + + ) : null} + +
+
+ ); +} diff --git a/apps/web/src/components/common/FormValidationError.css.ts b/apps/web/src/components/common/FormValidationError.css.ts new file mode 100644 index 00000000..bfe970bd --- /dev/null +++ b/apps/web/src/components/common/FormValidationError.css.ts @@ -0,0 +1,18 @@ +import { style } from '@vanilla-extract/css'; + +export const error = style({ + display: 'flex', + alignItems: 'flex-start', + gap: '4px', + marginTop: '4px', + color: 'var(--danger, #ff3358)', + fontSize: '11px', + lineHeight: 1.4, + fontFamily: 'var(--font-ui)', +}); + +export const icon = style({ + flexShrink: 0, + fontSize: '12px', + lineHeight: 1.4, +}); diff --git a/apps/web/src/components/common/FormValidationError.tsx b/apps/web/src/components/common/FormValidationError.tsx new file mode 100644 index 00000000..c2d7d2fe --- /dev/null +++ b/apps/web/src/components/common/FormValidationError.tsx @@ -0,0 +1,15 @@ +import { error, icon } from './FormValidationError.css.js'; + +interface FormValidationErrorProps { + message: string; + className?: string; +} + +export function FormValidationError({ message, className = '' }: FormValidationErrorProps) { + return ( + + ! + {message} + + ); +} diff --git a/apps/web/src/components/common/PnLAnimator.css.ts b/apps/web/src/components/common/PnLAnimator.css.ts new file mode 100644 index 00000000..0cdce4e4 --- /dev/null +++ b/apps/web/src/components/common/PnLAnimator.css.ts @@ -0,0 +1,19 @@ +import { style } from '@vanilla-extract/css'; + +export const value = style({ + display: 'inline-block', + fontVariantNumeric: 'tabular-nums', + transition: 'color 200ms ease', +}); + +export const positive = style({ + color: 'var(--buy)', +}); + +export const negative = style({ + color: 'var(--sell)', +}); + +export const zero = style({ + color: 'var(--muted)', +}); diff --git a/apps/web/src/components/common/PnLAnimator.tsx b/apps/web/src/components/common/PnLAnimator.tsx new file mode 100644 index 00000000..1d0f4f29 --- /dev/null +++ b/apps/web/src/components/common/PnLAnimator.tsx @@ -0,0 +1,63 @@ +import { useEffect, useRef, useState } from 'react'; +import { negative, positive, value, zero } from './PnLAnimator.css.js'; + +interface PnLAnimatorProps { + target: number; + precision?: number; + prefix?: string; + suffix?: string; + duration?: number; + className?: string; +} + +export function PnLAnimator({ + target, + precision = 2, + prefix = '', + suffix = '', + duration = 400, + className = '', +}: PnLAnimatorProps) { + const [display, setDisplay] = useState(target); + const rafRef = useRef(); + const startRef = useRef(target); + const fromRef = useRef(target); + const startTimeRef = useRef(0); + + useEffect(() => { + fromRef.current = startRef.current; + startTimeRef.current = performance.now(); + + const animate = (now: number) => { + const elapsed = now - startTimeRef.current; + const progress = Math.min(elapsed / duration, 1); + // ease-out cubic + const eased = 1 - (1 - progress) ** 3; + const current = fromRef.current + (target - fromRef.current) * eased; + setDisplay(current); + + if (progress < 1) { + rafRef.current = requestAnimationFrame(animate); + } else { + startRef.current = target; + } + }; + + rafRef.current = requestAnimationFrame(animate); + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, [target, duration]); + + const colorClass = display > 0 ? positive : display < 0 ? negative : zero; + const sign = display > 0 ? '+' : ''; + + return ( + + {prefix} + {sign} + {display.toFixed(precision)} + {suffix} + + ); +} diff --git a/apps/web/src/components/common/PriceFlash.css.ts b/apps/web/src/components/common/PriceFlash.css.ts new file mode 100644 index 00000000..b073328a --- /dev/null +++ b/apps/web/src/components/common/PriceFlash.css.ts @@ -0,0 +1,39 @@ +import { keyframes, style } from '@vanilla-extract/css'; + +const flashUp = keyframes({ + '0%': { backgroundColor: 'rgba(0, 232, 157, 0.35)' }, + '100%': { backgroundColor: 'transparent' }, +}); + +const flashDown = keyframes({ + '0%': { backgroundColor: 'rgba(255, 51, 88, 0.35)' }, + '100%': { backgroundColor: 'transparent' }, +}); + +export const wrapper = style({ + display: 'inline-block', + borderRadius: '3px', + transition: 'color 150ms ease', + padding: '1px 4px', + margin: '-1px -4px', +}); + +export const flashUpStyle = style({ + animation: `${flashUp} 200ms ease-out`, +}); + +export const flashDownStyle = style({ + animation: `${flashDown} 200ms ease-out`, +}); + +export const priceUp = style({ + color: 'var(--buy)', +}); + +export const priceDown = style({ + color: 'var(--sell)', +}); + +export const priceFlat = style({ + color: 'var(--text)', +}); diff --git a/apps/web/src/components/common/PriceFlash.tsx b/apps/web/src/components/common/PriceFlash.tsx new file mode 100644 index 00000000..ec93e085 --- /dev/null +++ b/apps/web/src/components/common/PriceFlash.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from 'react'; +import { + flashDownStyle, + flashUpStyle, + priceDown, + priceFlat, + priceUp, + wrapper, +} from './PriceFlash.css.js'; + +interface PriceFlashProps { + value: number; + precision?: number; + prefix?: string; + className?: string; +} + +export function PriceFlash({ value, precision = 2, prefix = '', className = '' }: PriceFlashProps) { + const prevRef = useRef(value); + const [flash, setFlash] = useState<'up' | 'down' | null>(null); + const [colorClass, setColorClass] = useState(priceFlat); + const timeoutRef = useRef>(); + + useEffect(() => { + const prev = prevRef.current; + if (value > prev) { + setFlash('up'); + setColorClass(priceUp); + } else if (value < prev) { + setFlash('down'); + setColorClass(priceDown); + } + prevRef.current = value; + + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + setFlash(null); + setColorClass(priceFlat); + }, 600); + + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [value]); + + const flashClass = flash === 'up' ? flashUpStyle : flash === 'down' ? flashDownStyle : ''; + + return ( + + {prefix} + {value.toFixed(precision)} + + ); +} diff --git a/apps/web/src/components/common/SignalAlert.css.ts b/apps/web/src/components/common/SignalAlert.css.ts new file mode 100644 index 00000000..c3b992fe --- /dev/null +++ b/apps/web/src/components/common/SignalAlert.css.ts @@ -0,0 +1,64 @@ +import { keyframes, style } from '@vanilla-extract/css'; + +const pulse = keyframes({ + '0%': { transform: 'scale(1)', opacity: '1' }, + '50%': { transform: 'scale(1.15)', opacity: '0.7' }, + '100%': { transform: 'scale(1)', opacity: '1' }, +}); + +const ring = keyframes({ + '0%': { transform: 'scale(1)', opacity: '0.6' }, + '100%': { transform: 'scale(2.2)', opacity: '0' }, +}); + +export const badge = style({ + position: 'relative', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', +}); + +export const dot = style({ + width: '8px', + height: '8px', + borderRadius: '50%', + background: 'var(--accent)', + position: 'relative', +}); + +export const dotBuy = style({ + background: 'var(--buy)', +}); + +export const dotSell = style({ + background: 'var(--sell)', +}); + +export const dotWarning = style({ + background: 'var(--warning)', +}); + +export const pulsing = style({ + animation: `${pulse} 600ms ease-in-out 3`, +}); + +export const ringEffect = style({ + position: 'absolute', + inset: 0, + borderRadius: '50%', + border: '2px solid var(--accent)', + animation: `${ring} 800ms ease-out 2`, + pointerEvents: 'none', +}); + +export const ringBuy = style({ + borderColor: 'var(--buy)', +}); + +export const ringSell = style({ + borderColor: 'var(--sell)', +}); + +export const ringWarning = style({ + borderColor: 'var(--warning)', +}); diff --git a/apps/web/src/components/common/SignalAlert.tsx b/apps/web/src/components/common/SignalAlert.tsx new file mode 100644 index 00000000..6dc8e3e7 --- /dev/null +++ b/apps/web/src/components/common/SignalAlert.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import { + badge, + dot, + dotBuy, + dotSell, + dotWarning, + pulsing, + ringBuy, + ringEffect, + ringSell, + ringWarning, +} from './SignalAlert.css.js'; + +interface SignalAlertProps { + variant?: 'buy' | 'sell' | 'warning'; + size?: number; + label?: string; + pulse?: boolean; + className?: string; +} + +export function SignalAlert({ + variant = 'buy', + size = 8, + label, + pulse = true, + className = '', +}: SignalAlertProps) { + const [animate, setAnimate] = useState(pulse); + + useEffect(() => { + if (pulse) { + setAnimate(true); + const t = setTimeout(() => setAnimate(false), 1800); + return () => clearTimeout(t); + } + }, [pulse]); + + const dotColor = variant === 'buy' ? dotBuy : variant === 'sell' ? dotSell : dotWarning; + const ringColor = variant === 'buy' ? ringBuy : variant === 'sell' ? ringSell : ringWarning; + + return ( + + + {animate && } + + ); +} diff --git a/apps/web/src/components/common/index.ts b/apps/web/src/components/common/index.ts new file mode 100644 index 00000000..c786ce35 --- /dev/null +++ b/apps/web/src/components/common/index.ts @@ -0,0 +1,13 @@ +export { + EmptyBacktests, + EmptyOrders, + EmptyPositions, + EmptySignals, + EmptyStrategies, + EmptyWatchlist, +} from './EmptyStates.js'; +export { ErrorBanner } from './ErrorBanner.js'; +export { FormValidationError } from './FormValidationError.js'; +export { PnLAnimator } from './PnLAnimator.js'; +export { PriceFlash } from './PriceFlash.js'; +export { SignalAlert } from './SignalAlert.js'; diff --git a/apps/web/src/components/layout/ConsoleChrome.css.ts b/apps/web/src/components/layout/ConsoleChrome.css.ts index 7c67840a..df51bb8e 100644 --- a/apps/web/src/components/layout/ConsoleChrome.css.ts +++ b/apps/web/src/components/layout/ConsoleChrome.css.ts @@ -1,5 +1,10 @@ import { globalStyle, style } from '@vanilla-extract/css'; +/* ── BREAKPOINTS ────────────────────────────────────────── */ + +const mobile = 'screen and (max-width: 640px)'; +const tablet = 'screen and (max-width: 1024px)'; + /* ── APP SHELL ──────────────────────────────────────────── */ export const appShell = style({ @@ -12,10 +17,20 @@ export const appShell = style({ gap: 0, padding: 0, transition: 'grid-template-columns 0.25s ease', + '@media': { + [tablet]: { + gridTemplateColumns: '1fr', + }, + }, }); export const appShellCollapsed = style({ gridTemplateColumns: '60px 1fr', + '@media': { + [tablet]: { + gridTemplateColumns: '1fr', + }, + }, }); globalStyle(`${appShell}::before`, { @@ -41,6 +56,11 @@ export const mainPanel = style({ maxWidth: '1480px', marginLeft: 'auto', marginRight: 'auto', + '@media': { + [mobile]: { + padding: '12px 12px 80px', + }, + }, }); globalStyle(`${mainPanel}::before`, { @@ -65,6 +85,11 @@ export const sidebar = style({ display: 'flex', flexDirection: 'column', overflow: 'visible', + '@media': { + [tablet]: { + display: 'none', + }, + }, }); globalStyle(`${sidebar}::after`, { @@ -261,6 +286,12 @@ export const topbarMeta = style({ gridTemplateColumns: 'repeat(3, minmax(112px, 1fr))', gap: '10px', minWidth: '360px', + '@media': { + [mobile]: { + gridTemplateColumns: '1fr', + minWidth: 0, + }, + }, }); export const metaCard = style({ @@ -305,3 +336,49 @@ export const metaValueAccent = style({ color: 'var(--accent)', textShadow: '0 0 20px rgba(0, 212, 255, 0.25)', }); + +/* ── MOBILE BOTTOM NAV ──────────────────────────────────── */ + +export const bottomNav = style({ + display: 'none', + '@media': { + [mobile]: { + position: 'fixed', + bottom: 0, + left: 0, + right: 0, + zIndex: 100, + display: 'flex', + justifyContent: 'space-around', + alignItems: 'center', + height: '56px', + background: 'rgba(10, 20, 46, 0.98)', + borderTop: '1px solid var(--line)', + backdropFilter: 'blur(12px)', + }, + }, +}); + +export const bottomNavItem = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '2px', + padding: '6px 12px', + background: 'transparent', + border: 'none', + color: 'var(--muted)', + fontSize: '10px', + fontFamily: 'var(--font-data)', + letterSpacing: '0.06em', + cursor: 'pointer', + textDecoration: 'none', + transition: 'color 150ms ease', + ':hover': { + color: 'var(--text)', + }, +}); + +export const bottomNavItemActive = style({ + color: 'var(--accent)', +}); diff --git a/apps/web/src/components/layout/ConsoleChrome.tsx b/apps/web/src/components/layout/ConsoleChrome.tsx index edcb6d03..806ca508 100644 --- a/apps/web/src/components/layout/ConsoleChrome.tsx +++ b/apps/web/src/components/layout/ConsoleChrome.tsx @@ -1,5 +1,6 @@ import { type ReactNode, useEffect, useRef, useState } from 'react'; import { NavLink, Outlet, useLocation } from 'react-router-dom'; +import { useThemeContext } from '../../hooks/ThemeProvider.tsx'; import { useMarketProviderStatus } from '../../hooks/useMarketProviderStatus.ts'; import { useSettingsNavigation } from '../../modules/console/console.hooks.ts'; import { type ConsolePageKey, copy, useLocale } from '../../modules/console/console.i18n.tsx'; @@ -44,6 +45,8 @@ import { toolbarTitle, topbarMeta, } from './ConsoleChrome.css.ts'; +import { MobileBottomNav } from './MobileBottomNav.tsx'; +import { ShortcutHelp } from './ShortcutHelp.tsx'; export type TopMetaItem = { label: string; @@ -57,7 +60,9 @@ export function SectionHeader({ routeKey }: { routeKey: ConsolePageKey }) { return (
-
{copy[locale].desk[routeKey]}
+
+ {(copy[locale].desk as Record)[routeKey] ?? ''} +

{title}

{desc}

@@ -179,6 +184,7 @@ function GlobalToolbar() { const { state } = useTradingSystem(); const { status: marketStatus } = useMarketProviderStatus(state.controlPlane.lastSyncAt); const goToSettings = useSettingsNavigation(); + const { resolved, toggle } = useThemeContext(); const [localeOpen, setLocaleOpen] = useState(false); const localeMenuRef = useRef(null); const localeLabel = locale === 'zh' ? '中文' : 'English'; @@ -274,6 +280,16 @@ function GlobalToolbar() {
) : null}
+
); @@ -320,14 +336,21 @@ export type EmptyStateProps = { icon?: string; message: string; detail?: string; + actionLabel?: string; + onAction?: () => void; }; -export function EmptyState({ icon, message, detail }: EmptyStateProps) { +export function EmptyState({ icon, message, detail, actionLabel, onAction }: EmptyStateProps) { return (
{icon ? {icon} : null} {message} {detail ? {detail} : null} + {actionLabel && onAction ? ( + + ) : null}
); } @@ -340,6 +363,7 @@ export function Layout() { return window.localStorage.getItem('quantpilot-sidebar-collapsed') === 'true'; }); const [cmdOpen, setCmdOpen] = useState(false); + const [shortcutsOpen, setShortcutsOpen] = useState(false); const handleToggle = () => { setCollapsed((prev) => { @@ -353,13 +377,44 @@ export function Layout() { document.title = getConsoleDocumentTitle(locale, location.pathname); }, [locale, location.pathname]); + // Keyboard shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + const meta = e.metaKey || e.ctrlKey; + + // Cmd+K: command palette + if (meta && e.key === 'k') { e.preventDefault(); setCmdOpen((prev) => !prev); + return; + } + + // Cmd+/: shortcut help + if (meta && e.key === '/') { + e.preventDefault(); + setShortcutsOpen((prev) => !prev); + return; + } + + // Escape: close modals + if (e.key === 'Escape') { + setCmdOpen(false); + setShortcutsOpen(false); + return; + } + + // Cmd+1-9: navigation + if (meta && e.key >= '1' && e.key <= '9') { + e.preventDefault(); + const routes = listSidebarRoutes(); + const idx = Number(e.key) - 1; + if (routes[idx]) { + window.location.hash = `#${routes[idx].path}`; + } + return; } }; + window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, []); @@ -372,6 +427,8 @@ export function Layout() { {cmdOpen && setCmdOpen(false)} />} + setShortcutsOpen(false)} /> + + {visibleRoutes.map((route) => ( + + `${bottomNavItem}${isActive ? ` ${bottomNavItemActive}` : ''}` + } + > + {getIcon(route.id)} + {copy[locale].nav[route.id]} + + ))} + + ); +} + +function getIcon(id: string): string { + const icons: Record = { + overview: '◈', + market: '◉', + strategies: '◇', + backtest: '⟐', + execution: '⬡', + risk: '△', + agent: '◎', + notifications: '⊡', + settings: '⚙', + }; + return icons[id] ?? '•'; +} diff --git a/apps/web/src/components/layout/ShortcutHelp.tsx b/apps/web/src/components/layout/ShortcutHelp.tsx new file mode 100644 index 00000000..6bf03c3b --- /dev/null +++ b/apps/web/src/components/layout/ShortcutHelp.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { + DEFAULT_SHORTCUTS, + type Shortcut, + type ShortcutCategory, +} from '../../hooks/useKeyboardShortcuts.ts'; +import { useLocale } from '../../modules/console/console.i18n.tsx'; + +interface ShortcutHelpProps { + open: boolean; + onClose: () => void; +} + +const CATEGORY_LABELS: Record = { + navigation: { zh: '导航', en: 'Navigation' }, + trading: { zh: '交易', en: 'Trading' }, + search: { zh: '搜索', en: 'Search' }, + general: { zh: '通用', en: 'General' }, +}; + +function formatKey(shortcut: Omit): string { + const parts: string[] = []; + if (shortcut.meta) parts.push('⌘'); + if (shortcut.shift) parts.push('⇧'); + parts.push(shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key); + return parts.join(''); +} + +export function ShortcutHelp({ open, onClose }: ShortcutHelpProps) { + const { locale } = useLocale(); + const [filter, setFilter] = useState(''); + + if (!open) return null; + + const filtered = DEFAULT_SHORTCUTS.filter( + (s) => + !filter || + s.label.toLowerCase().includes(filter.toLowerCase()) || + s.category.includes(filter.toLowerCase()) + ); + + const grouped = filtered.reduce( + (acc, s) => { + if (!acc[s.category]) acc[s.category] = []; + acc[s.category].push(s); + return acc; + }, + {} as Record + ); + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + onKeyDown={(e) => { + if (e.key === 'Escape') onClose(); + }} + > +
+
+

+ {locale === 'zh' ? '快捷键' : 'Keyboard Shortcuts'} +

+ +
+ + setFilter(e.target.value)} + style={{ + width: '100%', + padding: '8px 12px', + background: 'var(--panel)', + border: '1px solid var(--line)', + borderRadius: 'var(--radius)', + color: 'var(--text)', + fontSize: '13px', + marginBottom: '16px', + outline: 'none', + }} + /> + + {Object.entries(grouped).map(([category, items]) => ( +
+
+ {CATEGORY_LABELS[category as ShortcutCategory]?.[locale] ?? category} +
+ {items.map((s) => ( +
+ {s.label} + + {formatKey(s)} + +
+ ))} +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/layout/SplitPane.css.ts b/apps/web/src/components/layout/SplitPane.css.ts new file mode 100644 index 00000000..8beb2bce --- /dev/null +++ b/apps/web/src/components/layout/SplitPane.css.ts @@ -0,0 +1,51 @@ +import { style } from '@vanilla-extract/css'; + +export const container = style({ + display: 'flex', + overflow: 'hidden', + height: '100%', + width: '100%', +}); + +export const containerVertical = style({ + flexDirection: 'column', +}); + +export const pane = style({ + overflow: 'auto', + minWidth: 0, + minHeight: 0, +}); + +export const divider = style({ + flexShrink: 0, + background: 'var(--line)', + transition: 'background 150ms ease', + selectors: { + '&:hover': { + background: 'var(--accent)', + }, + '&:active': { + background: 'var(--accent)', + }, + }, +}); + +export const dividerHorizontal = style({ + width: '4px', + cursor: 'col-resize', + userSelect: 'none', +}); + +export const dividerVertical = style({ + height: '4px', + cursor: 'row-resize', + userSelect: 'none', +}); + +export const dragOverlay = style({ + position: 'fixed', + inset: 0, + zIndex: 9999, + cursor: 'col-resize', +}); diff --git a/apps/web/src/components/layout/SplitPane.tsx b/apps/web/src/components/layout/SplitPane.tsx new file mode 100644 index 00000000..ccb8a608 --- /dev/null +++ b/apps/web/src/components/layout/SplitPane.tsx @@ -0,0 +1,93 @@ +import { useCallback, useRef, useState } from 'react'; +import { + container, + containerVertical, + divider, + dividerHorizontal, + dividerVertical, + dragOverlay, + pane, +} from './SplitPane.css.js'; + +interface SplitPaneProps { + direction?: 'horizontal' | 'vertical'; + defaultSize?: number; + minSize?: number; + maxSize?: number; + children: [React.ReactNode, React.ReactNode]; + className?: string; + onResize?: (size: number) => void; +} + +export function SplitPane({ + direction = 'horizontal', + defaultSize = 300, + minSize = 100, + maxSize = Infinity, + children, + className = '', + onResize, +}: SplitPaneProps) { + const [size, setSize] = useState(defaultSize); + const dragging = useRef(false); + const startRef = useRef({ pos: 0, size: 0 }); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + dragging.current = true; + startRef.current = { + pos: direction === 'horizontal' ? e.clientX : e.clientY, + size, + }; + + const handleMouseMove = (ev: MouseEvent) => { + if (!dragging.current) return; + const delta = (direction === 'horizontal' ? ev.clientX : ev.clientY) - startRef.current.pos; + const newSize = Math.max(minSize, Math.min(maxSize, startRef.current.size + delta)); + setSize(newSize); + onResize?.(newSize); + }; + + const handleMouseUp = () => { + dragging.current = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, + [direction, size, minSize, maxSize, onResize] + ); + + const isHorizontal = direction === 'horizontal'; + const sizeProp = isHorizontal ? 'width' : 'height'; + + return ( +
+
+ {children[0]} +
+
+
+ {children[1]} +
+ {dragging.current && ( +
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/TradingWorkspace.css.ts b/apps/web/src/components/layout/TradingWorkspace.css.ts new file mode 100644 index 00000000..8ed1244d --- /dev/null +++ b/apps/web/src/components/layout/TradingWorkspace.css.ts @@ -0,0 +1,48 @@ +import { style } from '@vanilla-extract/css'; + +export const workspace = style({ + display: 'flex', + flexDirection: 'column', + height: '100%', + overflow: 'hidden', +}); + +export const toolbar = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '6px 12px', + background: 'var(--panel)', + borderBottom: '1px solid var(--line)', + flexShrink: 0, +}); + +export const toolbarBtn = style({ + padding: '4px 10px', + background: 'transparent', + border: '1px solid var(--line)', + borderRadius: 'var(--radius)', + color: 'var(--muted)', + fontFamily: 'var(--font-data)', + fontSize: '11px', + fontWeight: 600, + cursor: 'pointer', + transition: 'all 150ms ease', + selectors: { + '&:hover': { + borderColor: 'var(--accent)', + color: 'var(--text)', + }, + }, +}); + +export const toolbarBtnActive = style({ + background: 'var(--accent-subtle)', + borderColor: 'var(--accent)', + color: 'var(--accent)', +}); + +export const layoutArea = style({ + flex: 1, + overflow: 'hidden', +}); diff --git a/apps/web/src/components/layout/TradingWorkspace.tsx b/apps/web/src/components/layout/TradingWorkspace.tsx new file mode 100644 index 00000000..45234c10 --- /dev/null +++ b/apps/web/src/components/layout/TradingWorkspace.tsx @@ -0,0 +1,89 @@ +import { type ReactNode, useCallback, useState } from 'react'; +import { SplitPane } from './SplitPane.js'; +import { + layoutArea, + toolbar, + toolbarBtn, + toolbarBtnActive, + workspace, +} from './TradingWorkspace.css.js'; + +export type LayoutPreset = 'default' | 'chart-focus' | 'order-focus' | 'monitor'; + +interface Panel { + id: string; + content: ReactNode; +} + +interface TradingWorkspaceProps { + panels: Panel[]; + className?: string; +} + +const STORAGE_KEY = 'qp-trading-layout'; + +const presets: Record = { + default: { split: 300, direction: 'horizontal' }, + 'chart-focus': { split: 200, direction: 'horizontal' }, + 'order-focus': { split: 400, direction: 'horizontal' }, + monitor: { split: 250, direction: 'vertical' }, +}; + +function loadPreset(): LayoutPreset { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved && saved in presets) return saved as LayoutPreset; + } catch { + // ignore + } + return 'default'; +} + +export function TradingWorkspace({ panels, className = '' }: TradingWorkspaceProps) { + const [preset, setPreset] = useState(loadPreset); + + const handlePresetChange = useCallback((p: LayoutPreset) => { + setPreset(p); + try { + localStorage.setItem(STORAGE_KEY, p); + } catch { + // ignore + } + }, []); + + const { split, direction } = presets[preset]; + + if (panels.length < 2) { + return
{panels[0]?.content}
; + } + + const left = panels[0].content; + const right = panels.slice(1).map((p) => p.content); + + return ( +
+
+ {(Object.keys(presets) as LayoutPreset[]).map((p) => ( + + ))} +
+
+ + {left} +
+ {right} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/onboarding/Onboarding.css.ts b/apps/web/src/components/onboarding/Onboarding.css.ts new file mode 100644 index 00000000..806be01a --- /dev/null +++ b/apps/web/src/components/onboarding/Onboarding.css.ts @@ -0,0 +1,170 @@ +import { keyframes, style } from '@vanilla-extract/css'; + +const fadeIn = keyframes({ + from: { opacity: 0, transform: 'translateY(8px)' }, + to: { opacity: 1, transform: 'translateY(0)' }, +}); + +export const overlay = style({ + position: 'fixed', + inset: 0, + zIndex: 9500, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'rgba(3, 4, 18, 0.85)', + backdropFilter: 'blur(8px)', + animation: `${fadeIn} 200ms ease both`, +}); + +export const wizard = style({ + width: '100%', + maxWidth: '520px', + borderRadius: 'var(--radius-lg)', + border: '1px solid rgba(99, 102, 241, 0.2)', + background: 'rgba(12, 15, 40, 0.98)', + boxShadow: '0 32px 80px rgba(0, 0, 0, 0.75), 0 0 60px rgba(99, 102, 241, 0.08)', + overflow: 'hidden', + animation: `${fadeIn} 250ms cubic-bezier(0.16, 1, 0.3, 1) both`, +}); + +export const header = style({ + padding: '24px 28px 0', +}); + +export const progress = style({ + display: 'flex', + gap: '4px', + marginBottom: '20px', +}); + +export const progressBar = style({ + flex: 1, + height: '3px', + borderRadius: '2px', + background: 'rgba(99, 102, 241, 0.12)', + transition: 'background 200ms ease', +}); + +export const progressBarActive = style({ + background: 'var(--accent)', +}); + +export const title = style({ + font: '700 20px/1.2 var(--font-ui)', + color: 'var(--text)', + marginBottom: '8px', +}); + +export const subtitle = style({ + font: '400 13px/1.5 var(--font-ui)', + color: 'var(--muted)', +}); + +export const body = style({ + padding: '24px 28px', + minHeight: '200px', +}); + +export const stepContent = style({ + animation: `${fadeIn} 200ms ease both`, +}); + +export const footer = style({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '16px 28px', + borderTop: '1px solid rgba(99, 102, 241, 0.1)', + background: 'rgba(99, 102, 241, 0.03)', +}); + +export const btn = style({ + padding: '8px 20px', + borderRadius: 'var(--radius)', + font: '600 13px/1 var(--font-ui)', + cursor: 'pointer', + transition: 'all 150ms ease', + border: 'none', +}); + +export const btnPrimary = style({ + background: 'var(--accent)', + color: '#fff', + selectors: { + '&:hover': { + background: 'var(--accent-hover)', + }, + }, +}); + +export const btnSecondary = style({ + background: 'transparent', + border: '1px solid var(--line)', + color: 'var(--muted)', + selectors: { + '&:hover': { + borderColor: 'var(--accent)', + color: 'var(--text)', + }, + }, +}); + +export const btnSkip = style({ + background: 'transparent', + border: 'none', + color: 'var(--muted)', + opacity: 0.6, + font: '400 12px/1 var(--font-ui)', + cursor: 'pointer', + transition: 'opacity 150ms ease', + selectors: { + '&:hover': { + opacity: 1, + }, + }, +}); + +export const featureList = style({ + display: 'flex', + flexDirection: 'column', + gap: '12px', + marginTop: '16px', +}); + +export const featureItem = style({ + display: 'flex', + alignItems: 'flex-start', + gap: '10px', + padding: '12px', + borderRadius: 'var(--radius)', + background: 'rgba(99, 102, 241, 0.06)', + border: '1px solid rgba(99, 102, 241, 0.1)', +}); + +export const featureIcon = style({ + flexShrink: 0, + width: '32px', + height: '32px', + borderRadius: 'var(--radius-sm)', + background: 'rgba(99, 102, 241, 0.12)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '16px', +}); + +export const featureText = style({ + flex: 1, +}); + +export const featureTitle = style({ + font: '600 13px/1.4 var(--font-ui)', + color: 'var(--text)', + marginBottom: '2px', +}); + +export const featureDesc = style({ + font: '400 12px/1.4 var(--font-ui)', + color: 'var(--muted)', +}); diff --git a/apps/web/src/components/onboarding/OnboardingWizard.tsx b/apps/web/src/components/onboarding/OnboardingWizard.tsx new file mode 100644 index 00000000..60914665 --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingWizard.tsx @@ -0,0 +1,284 @@ +import { type ReactNode, useCallback, useState } from 'react'; +import { + body, + btn, + btnPrimary, + btnSecondary, + btnSkip, + featureDesc, + featureIcon, + featureItem, + featureList, + featureText, + featureTitle, + footer, + header, + overlay, + progress, + progressBar, + progressBarActive, + stepContent, + subtitle, + title, + wizard, +} from './Onboarding.css.js'; + +const STORAGE_KEY = 'qp-onboarding-complete'; + +interface Step { + id: string; + title: string; + subtitle: string; + content: ReactNode; +} + +interface OnboardingWizardProps { + locale?: 'zh' | 'en'; + onComplete: () => void; +} + +function isOnboardingComplete(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function markComplete() { + try { + localStorage.setItem(STORAGE_KEY, 'true'); + } catch { + // ignore + } +} + +const FEATURES_ZH = [ + { icon: '📊', title: '策略回测', desc: '在历史数据上验证你的量化策略' }, + { icon: '⚡', title: '实时信号', desc: 'AI 驱动的买卖信号实时推送' }, + { icon: '🛡️', title: '风控系统', desc: '多层风控参数保护你的资金安全' }, + { icon: '🤖', title: 'Agent 协作', desc: 'AI Agent 辅助分析和决策' }, +]; + +const FEATURES_EN = [ + { + icon: '📊', + title: 'Strategy Backtesting', + desc: 'Validate your strategies against historical data', + }, + { icon: '⚡', title: 'Real-time Signals', desc: 'AI-driven buy/sell signals in real-time' }, + { + icon: '🛡️', + title: 'Risk Management', + desc: 'Multi-layer risk parameters to protect your capital', + }, + { + icon: '🤖', + title: 'Agent Collaboration', + desc: 'AI agent assisted analysis and decision-making', + }, +]; + +export function OnboardingWizard({ locale = 'en', onComplete }: OnboardingWizardProps) { + const [currentStep, setCurrentStep] = useState(0); + + const steps: Step[] = [ + { + id: 'welcome', + title: locale === 'zh' ? '欢迎使用 QuantPilot' : 'Welcome to QuantPilot', + subtitle: + locale === 'zh' + ? 'AI 原生量化交易平台,让策略研究、回测和执行变得简单高效。' + : 'The AI-native quantitative trading platform that simplifies strategy research, backtesting, and execution.', + content: ( +
+ {(locale === 'zh' ? FEATURES_ZH : FEATURES_EN).map((f) => ( +
+
{f.icon}
+
+
{f.title}
+
{f.desc}
+
+
+ ))} +
+ ), + }, + { + id: 'broker', + title: locale === 'zh' ? '连接券商' : 'Connect Broker', + subtitle: + locale === 'zh' + ? '在设置页面配置你的券商 API 密钥,连接实盘或模拟账户。' + : 'Configure your broker API keys in Settings to connect live or paper accounts.', + content: ( +
+
+
🔑
+
+
{locale === 'zh' ? 'API 密钥' : 'API Keys'}
+
+ {locale === 'zh' + ? '在 设置 → 券商连接 中添加你的 API Key 和 Secret' + : 'Add your API Key and Secret in Settings → Broker Connection'} +
+
+
+
+
📋
+
+
{locale === 'zh' ? '模拟账户' : 'Paper Account'}
+
+ {locale === 'zh' + ? '默认启用模拟账户,无需真实资金即可体验完整功能' + : 'Paper account is enabled by default — experience full features without real capital'} +
+
+
+
+ ), + }, + { + id: 'risk', + title: locale === 'zh' ? '设置风控参数' : 'Set Risk Parameters', + subtitle: + locale === 'zh' + ? '配置止损、仓位限制等风控参数,保护你的资金安全。' + : 'Configure stop-loss, position limits, and other risk parameters to protect your capital.', + content: ( +
+
+
🛑
+
+
{locale === 'zh' ? '止损规则' : 'Stop-Loss Rules'}
+
+ {locale === 'zh' + ? '设置最大回撤和单笔亏损限制' + : 'Set maximum drawdown and per-trade loss limits'} +
+
+
+
+
📊
+
+
{locale === 'zh' ? '仓位控制' : 'Position Sizing'}
+
+ {locale === 'zh' + ? '配置最大持仓比例和单标的上限' + : 'Configure max exposure ratios and per-symbol limits'} +
+
+
+
+ ), + }, + { + id: 'backtest', + title: locale === 'zh' ? '运行第一次回测' : 'Run Your First Backtest', + subtitle: + locale === 'zh' + ? '选择策略和历史数据区间,评估策略表现。' + : 'Select a strategy and historical date range to evaluate performance.', + content: ( +
+
+
🔬
+
+
{locale === 'zh' ? '选择策略' : 'Select Strategy'}
+
+ {locale === 'zh' + ? '从策略库中选择一个评分较高的策略' + : 'Choose a high-scoring strategy from your strategy library'} +
+
+
+
+
📅
+
+
+ {locale === 'zh' ? '设置时间区间' : 'Set Date Range'} +
+
+ {locale === 'zh' + ? '建议使用 1-3 年的历史数据进行回测' + : 'Recommended: 1-3 years of historical data for backtesting'} +
+
+
+
+ ), + }, + ]; + + const handleNext = useCallback(() => { + if (currentStep < steps.length - 1) { + setCurrentStep((prev) => prev + 1); + } else { + markComplete(); + onComplete(); + } + }, [currentStep, steps.length, onComplete]); + + const handleBack = useCallback(() => { + setCurrentStep((prev) => Math.max(0, prev - 1)); + }, []); + + const handleSkip = useCallback(() => { + markComplete(); + onComplete(); + }, [onComplete]); + + const step = steps[currentStep]; + const isLast = currentStep === steps.length - 1; + + return ( +
+
+
+
+ {steps.map((s, i) => ( +
+ ))} +
+

{step.title}

+

{step.subtitle}

+
+ +
+
+ {step.content} +
+
+ +
+
+ {currentStep > 0 && ( + + )} +
+
+ + +
+
+
+
+ ); +} + +export { isOnboardingComplete }; diff --git a/apps/web/src/components/risk/PortfolioGreeks.tsx b/apps/web/src/components/risk/PortfolioGreeks.tsx new file mode 100644 index 00000000..b5a2b85d --- /dev/null +++ b/apps/web/src/components/risk/PortfolioGreeks.tsx @@ -0,0 +1,196 @@ +import { useLocale } from '../../modules/console/console.i18n.tsx'; + +interface Greeks { + delta: number; + gamma: number; + theta: number; + vega: number; + rho: number; +} + +interface PortfolioGreeksProps { + greeks: Greeks; + underlyingPrice?: number; + className?: string; +} + +export function PortfolioGreeks({ + greeks, + underlyingPrice = 100, + className = '', +}: PortfolioGreeksProps) { + const { locale } = useLocale(); + + const metrics = [ + { + label: 'Delta', + labelZh: 'Delta', + value: greeks.delta, + description: + locale === 'zh' ? '标的价格变动 1 美元时的损益变化' : 'P&L change when underlying moves $1', + format: (v: number) => v.toFixed(2), + color: greeks.delta >= 0 ? 'var(--buy)' : 'var(--sell)', + }, + { + label: 'Gamma', + labelZh: 'Gamma', + value: greeks.gamma, + description: + locale === 'zh' ? 'Delta 的变化率(凸性)' : 'Rate of change of delta (convexity)', + format: (v: number) => v.toFixed(4), + color: 'var(--text)', + }, + { + label: 'Theta', + labelZh: 'Theta', + value: greeks.theta, + description: locale === 'zh' ? '每日时间衰减' : 'Daily time decay', + format: (v: number) => v.toFixed(2), + color: greeks.theta <= 0 ? 'var(--sell)' : 'var(--buy)', + }, + { + label: 'Vega', + labelZh: 'Vega', + value: greeks.vega, + description: + locale === 'zh' ? '波动率变动 1% 时的损益变化' : 'P&L change when volatility moves 1%', + format: (v: number) => v.toFixed(2), + color: 'var(--text)', + }, + { + label: 'Rho', + labelZh: 'Rho', + value: greeks.rho, + description: + locale === 'zh' ? '利率变动 1% 时的损益变化' : 'P&L change when interest rate moves 1%', + format: (v: number) => v.toFixed(2), + color: 'var(--text)', + }, + ]; + + // Calculate dollar delta (delta * underlying price * 100 for options) + const dollarDelta = greeks.delta * underlyingPrice * 100; + + return ( +
+

+ {locale === 'zh' ? '组合 Greeks' : 'Portfolio Greeks'} +

+ + {/* Summary */} +
+
+
+ {locale === 'zh' ? '美元 Delta' : 'Dollar Delta'} +
+
= 0 ? 'var(--buy)' : 'var(--sell)', + marginTop: '4px', + }} + > + {dollarDelta >= 0 ? '+' : ''} + {dollarDelta.toFixed(0)} +
+
+
+
+ {locale === 'zh' ? '每日 Theta' : 'Daily Theta'} +
+
+ {greeks.theta <= 0 ? '' : '+'} + {greeks.theta.toFixed(2)} +
+
+
+ + {/* Detailed Greeks */} +
+ {metrics.map((metric) => ( +
+
+
+ {locale === 'zh' ? metric.labelZh : metric.label} +
+
+ {metric.description} +
+
+
+ {metric.format(metric.value)} +
+
+ ))} +
+ + {/* Greeks exposure bar */} +
+
+ {locale === 'zh' ? 'Delta 暴露' : 'Delta Exposure'} +
+
+
+
= 0 ? '50%' : `${50 + (greeks.delta / 100) * 50}%`, + width: `${Math.abs(greeks.delta / 100) * 50}%`, + background: greeks.delta >= 0 ? 'var(--buy)' : 'var(--sell)', + borderRadius: greeks.delta >= 0 ? '0 4px 4px 0' : '4px 0 0 4px', + }} + /> +
+
+
+ ); +} diff --git a/apps/web/src/components/skeletons/OverviewSkeleton.tsx b/apps/web/src/components/skeletons/OverviewSkeleton.tsx new file mode 100644 index 00000000..0d0ad672 --- /dev/null +++ b/apps/web/src/components/skeletons/OverviewSkeleton.tsx @@ -0,0 +1,78 @@ +import { Skeleton, SkeletonTable } from '@quantpilot/ui'; + +export function OverviewSkeleton() { + return ( +
+ {/* KPI banner skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ + {/* Chart + sidebar skeleton */} +
+
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + {/* Blotter skeleton */} +
+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/skeletons/RiskSkeleton.tsx b/apps/web/src/components/skeletons/RiskSkeleton.tsx new file mode 100644 index 00000000..033229ec --- /dev/null +++ b/apps/web/src/components/skeletons/RiskSkeleton.tsx @@ -0,0 +1,57 @@ +import { Skeleton, SkeletonTable } from '@quantpilot/ui'; + +export function RiskSkeleton() { + return ( +
+ {/* Metric cards */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ +
+ +
+
+ +
+
+ ))} +
+ + {/* Chart */} +
+ +
+ +
+
+ + {/* Risk events table */} +
+ +
+ +
+
+
+ ); +} diff --git a/apps/web/src/components/skeletons/StrategiesSkeleton.tsx b/apps/web/src/components/skeletons/StrategiesSkeleton.tsx new file mode 100644 index 00000000..8579c803 --- /dev/null +++ b/apps/web/src/components/skeletons/StrategiesSkeleton.tsx @@ -0,0 +1,24 @@ +import { Skeleton, SkeletonTable } from '@quantpilot/ui'; + +export function StrategiesSkeleton() { + return ( +
+ {/* Header */} +
+ + +
+ + {/* Strategies table */} +
+ +
+
+ ); +} diff --git a/apps/web/src/components/skeletons/TradingSkeleton.tsx b/apps/web/src/components/skeletons/TradingSkeleton.tsx new file mode 100644 index 00000000..0c2804a1 --- /dev/null +++ b/apps/web/src/components/skeletons/TradingSkeleton.tsx @@ -0,0 +1,115 @@ +import { Skeleton, SkeletonTable } from '@quantpilot/ui'; + +export function TradingSkeleton() { + return ( +
+ {/* Header skeleton */} +
+ +
+ + +
+
+ + +
+
+ + {/* Three-column grid */} +
+ {/* Watchlist */} +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ + {/* Chart */} +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ + {/* Order form + blotter */} +
+
+ +
+ + + + +
+
+
+ +
+ +
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/strategies/ActivityLog.tsx b/apps/web/src/components/strategies/ActivityLog.tsx new file mode 100644 index 00000000..6b661e3a --- /dev/null +++ b/apps/web/src/components/strategies/ActivityLog.tsx @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useLocale } from '../../modules/console/console.i18n.tsx'; + +interface Activity { + id: string; + strategyId: string; + userId: string; + userName: string; + action: string; + details: Record; + createdAt: string; +} + +interface ActivityLogProps { + strategyId: string; + className?: string; +} + +const ACTION_LABELS: Record = { + share: { en: 'Shared strategy', zh: '分享了策略' }, + comment: { en: 'Added comment', zh: '添加了评论' }, + resolve_comment: { en: 'Resolved comment', zh: '解决了评论' }, + edit: { en: 'Edited strategy', zh: '编辑了策略' }, + fork: { en: 'Forked strategy', zh: '复制了策略' }, +}; + +const ACTION_ICONS: Record = { + share: '🔗', + comment: '💬', + resolve_comment: '✅', + edit: '✏️', + fork: '🍴', +}; + +export function ActivityLog({ strategyId, className = '' }: ActivityLogProps) { + const { locale } = useLocale(); + const [activity, setActivity] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState('all'); + + const fetchActivity = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + if (filter !== 'all') params.set('action', filter); + + const res = await fetch(`/api/strategies/${strategyId}/activity?${params}`); + const data = await res.json(); + + if (data.ok) { + setActivity(data.activity); + } + } catch (err) { + console.error('Failed to fetch activity:', err); + } finally { + setLoading(false); + } + }, [strategyId, filter]); + + useEffect(() => { + fetchActivity(); + }, [fetchActivity]); + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return locale === 'zh' ? '刚刚' : 'Just now'; + if (diffMins < 60) return `${diffMins} ${locale === 'zh' ? '分钟前' : 'mins ago'}`; + if (diffHours < 24) return `${diffHours} ${locale === 'zh' ? '小时前' : 'hours ago'}`; + if (diffDays < 7) return `${diffDays} ${locale === 'zh' ? '天前' : 'days ago'}`; + + return date.toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }); + }; + + const getActionLabel = (action: string) => { + const labels = ACTION_LABELS[action]; + return labels ? (locale === 'zh' ? labels.zh : labels.en) : action; + }; + + const getActionIcon = (action: string) => { + return ACTION_ICONS[action] || '📋'; + }; + + if (loading) { + return ( +
+ {locale === 'zh' ? '加载活动记录...' : 'Loading activity...'} +
+ ); + } + + return ( +
+
+

+ {locale === 'zh' ? '活动记录' : 'Activity Log'} +

+ +
+ + {/* Activity list */} +
+ {activity.length === 0 ? ( +
+ {locale === 'zh' ? '暂无活动记录' : 'No activity yet'} +
+ ) : ( + activity.map((item, idx) => ( +
+ {/* Icon */} +
+ {getActionIcon(item.action)} +
+ + {/* Content */} +
+
+ {item.userName}{' '} + {getActionLabel(item.action)} +
+
+ {formatDate(item.createdAt)} +
+
+
+ )) + )} +
+
+ ); +} diff --git a/apps/web/src/components/strategies/CommentThread.tsx b/apps/web/src/components/strategies/CommentThread.tsx new file mode 100644 index 00000000..910d408c --- /dev/null +++ b/apps/web/src/components/strategies/CommentThread.tsx @@ -0,0 +1,353 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useLocale } from '../../modules/console/console.i18n.tsx'; + +interface Comment { + id: string; + strategyId: string; + userId: string; + userName: string; + content: string; + parentId: string | null; + resolved: boolean; + createdAt: string; + updatedAt: string; +} + +interface CommentThreadProps { + strategyId: string; + className?: string; +} + +export function CommentThread({ strategyId, className = '' }: CommentThreadProps) { + const { locale } = useLocale(); + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [newComment, setNewComment] = useState(''); + const [replyTo, setReplyTo] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const fetchComments = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`/api/strategies/${strategyId}/comments`); + const data = await res.json(); + if (data.ok) { + setComments(data.comments); + } + } catch (err) { + console.error('Failed to fetch comments:', err); + } finally { + setLoading(false); + } + }, [strategyId]); + + useEffect(() => { + fetchComments(); + }, [fetchComments]); + + const handleSubmit = async () => { + if (!newComment.trim()) return; + + setSubmitting(true); + try { + const res = await fetch(`/api/strategies/${strategyId}/comments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: newComment.trim(), + parentId: replyTo, + }), + }); + + const data = await res.json(); + if (data.ok) { + setNewComment(''); + setReplyTo(null); + fetchComments(); + } + } catch (err) { + console.error('Failed to add comment:', err); + } finally { + setSubmitting(false); + } + }; + + const handleResolve = async (commentId: string) => { + try { + const res = await fetch(`/api/strategies/comments/${commentId}/resolve`, { + method: 'POST', + }); + + const data = await res.json(); + if (data.ok) { + fetchComments(); + } + } catch (err) { + console.error('Failed to resolve comment:', err); + } + }; + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return date.toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + // Organize comments into threads + const rootComments = comments.filter((c) => !c.parentId); + const replies = comments.filter((c) => c.parentId); + + const getReplies = (parentId: string) => replies.filter((r) => r.parentId === parentId); + + if (loading) { + return ( +
+ {locale === 'zh' ? '加载评论...' : 'Loading comments...'} +
+ ); + } + + return ( +
+

+ {locale === 'zh' ? '评论' : 'Comments'} ({comments.length}) +

+ + {/* New comment input */} +
+ {replyTo && ( +
+ + {locale === 'zh' ? '回复' : 'Replying to'}{' '} + {comments.find((c) => c.id === replyTo)?.userName} + + +
+ )} +