feat: autonomous trading engine — full implementation

- Database migration 018 with 13 tables for trading engine state
- Trading engine service (services/trading/) with 12 pure computation modules:
  position sizer, stop-loss manager, reserve pool, circuit breaker,
  risk tier controller, correlation matrix, tax lots, trading window,
  gradual entry, notifications, micro-trading, backtester
- Core TradingEngine with pre-trade evaluation pipeline and integration wiring
- FastAPI HTTP service with 14 endpoints (health, config, decisions, metrics, backtest)
- Performance tracker with Sharpe ratio, drawdown, profit factor computation
- 194 Python tests (165 property-based + 29 integration)
- Frontend: 13 TanStack Query hooks, 7 dashboard panels, tabbed Trading Engine page
- Helm chart entry, network policy, nginx proxy, ingress for trading-engine
- Shared infrastructure: enums, Redis keys, TradingConfig in AppConfig
This commit is contained in:
Celes Renata
2026-04-15 16:12:22 +00:00
parent da86132f0c
commit 4ffde8cc06
58 changed files with 14168 additions and 1 deletions
+9
View File
@@ -36,6 +36,15 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy Trading Engine
location /trading/ {
proxy_pass http://trading-engine:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Cache static assets
location /assets/ {
expires 1y;
+4 -1
View File
@@ -6,8 +6,9 @@
const QUERY_API_BASE = import.meta.env.VITE_QUERY_API_URL || '';
const SYMBOL_REGISTRY_BASE = import.meta.env.VITE_SYMBOL_REGISTRY_URL || '/registry';
const RISK_ENGINE_BASE = import.meta.env.VITE_RISK_ENGINE_URL || '/risk';
const TRADING_ENGINE_BASE = import.meta.env.VITE_TRADING_ENGINE_URL || '/trading';
export type ApiBase = 'query' | 'registry' | 'risk';
export type ApiBase = 'query' | 'registry' | 'risk' | 'trading';
function baseUrl(api: ApiBase): string {
switch (api) {
@@ -17,6 +18,8 @@ function baseUrl(api: ApiBase): string {
return SYMBOL_REGISTRY_BASE;
case 'risk':
return RISK_ENGINE_BASE;
case 'trading':
return TRADING_ENGINE_BASE;
}
}
+315
View File
@@ -0,0 +1,315 @@
/**
* TanStack Query hooks for the Trading Engine API.
* All endpoints are proxied through /trading/ in nginx.
* Requirements: 14.4, 14.5, 14.6, 14.7, 15.6, 16.5, 17.4, 19.10, 20.9
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPut } from './client';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TradingEngineStatus {
enabled: boolean;
paused: boolean;
risk_tier: string;
circuit_breaker_active: boolean;
circuit_breaker_trigger_type: string | null;
circuit_breaker_cooldown_expires: string | null;
active_pool: number;
reserve_pool: number;
portfolio_heat: number;
portfolio_value: number;
open_position_count: number;
last_decision_at: string | null;
micro_trading_enabled: boolean;
uptime_seconds: number | null;
}
export interface TradingDecision {
id: string;
recommendation_id: string | null;
decision: string;
skip_reason: string | null;
ticker: string;
computed_position_size: number | null;
computed_share_quantity: number | null;
risk_tier_at_decision: string;
portfolio_heat_at_decision: number | null;
active_pool_at_decision: number | null;
reserve_pool_at_decision: number | null;
circuit_breaker_status: string;
correlation_check_result: Record<string, unknown>;
sector_exposure_check_result: Record<string, unknown>;
earnings_proximity_flag: boolean;
is_micro_trade: boolean;
decision_trace: Record<string, unknown>;
created_at: string;
}
export interface TradingMetrics {
total_portfolio_value: number;
active_pool: number;
reserve_pool: number;
unrealized_pnl: number;
realized_pnl: number;
daily_pnl: number;
win_count: number;
loss_count: number;
win_rate: number;
avg_win: number;
avg_loss: number;
profit_factor: number;
sharpe_ratio: number;
max_drawdown: number;
current_drawdown_pct: number;
portfolio_heat: number;
computed_at: string;
}
export interface PortfolioSnapshot {
id: string;
snapshot_date: string;
portfolio_value: number;
active_pool: number;
reserve_pool: number;
daily_return: number | null;
cumulative_return: number | null;
unrealized_pnl: number | null;
realized_pnl: number | null;
win_count: number;
loss_count: number;
win_rate: number | null;
sharpe_ratio: number | null;
max_drawdown: number | null;
current_drawdown_pct: number | null;
portfolio_heat: number | null;
risk_tier: string | null;
positions: unknown[];
metrics: Record<string, unknown>;
created_at: string;
}
export interface TradingEngineConfig {
id: string;
enabled: boolean;
paused: boolean;
risk_tier: string;
reserve_siphon_pct: number;
polling_interval_seconds: number;
gradual_entry_tranches: number;
gradual_entry_threshold_dollars: number;
gradual_entry_interval_minutes: number;
trading_window_start_minutes: number;
trading_window_end_minutes: number;
max_open_positions: number;
circuit_breaker_daily_loss_pct: number;
circuit_breaker_single_position_loss_pct: number;
circuit_breaker_ticker_cooldown_hours: number;
circuit_breaker_volatility_pause_hours: number;
circuit_breaker_stop_loss_hits_threshold: number;
circuit_breaker_stop_loss_window_minutes: number;
active_pool_minimum: number;
emergency_drawdown_threshold_pct: number;
reserve_high_water_pct: number;
absolute_position_cap: number;
correlation_reduction_threshold: number;
correlation_rejection_threshold: number;
earnings_pre_window_days: number;
earnings_post_cooldown_days: number;
micro_trading_enabled: boolean;
micro_trading_interval_seconds: number;
micro_trading_allocation_cap_pct: number;
micro_trading_max_daily: number;
micro_trading_max_hold_minutes: number;
notification_sms_enabled: boolean;
notification_email_enabled: boolean;
notification_phone_number: string | null;
notification_email_recipient: string | null;
notification_sns_topic_arn: string | null;
notification_rate_limit_sms_per_hour: number;
notification_rate_limit_email_per_hour: number;
notification_daily_summary_time: string;
created_at: string;
updated_at: string;
}
export interface BacktestResult {
id: string;
start_date: string;
end_date: string;
initial_capital: number;
risk_tier: string;
config: Record<string, unknown>;
total_return: number | null;
sharpe_ratio: number | null;
max_drawdown: number | null;
win_rate: number | null;
profit_factor: number | null;
trade_count: number | null;
equity_curve: Array<{ date: string; portfolio_value: number }>;
status: string;
completed_at: string | null;
created_at: string;
}
export interface NotificationConfig {
sms_enabled: boolean;
email_enabled: boolean;
phone_number: string | null;
email_recipient: string | null;
sns_topic_arn: string | null;
rate_limit_sms_per_hour: number;
rate_limit_email_per_hour: number;
daily_summary_time: string;
}
export interface NotificationRecord {
id: string;
channel: string;
event_type: string;
message: string;
delivery_status: string;
retry_count: number;
error_message: string | null;
created_at: string;
delivered_at: string | null;
}
// ---------------------------------------------------------------------------
// Query hooks
// ---------------------------------------------------------------------------
/** Fetch current trading engine status (risk tier, circuit breaker, pools, etc.) */
export function useTradingStatus() {
return useQuery<TradingEngineStatus>({
queryKey: ['trading-status'],
queryFn: () => apiGet<TradingEngineStatus>('trading', '/api/trading/status'),
});
}
/** Fetch recent trading decisions with optional pagination and filters. */
export function useTradingDecisions(params?: {
ticker?: string;
decision?: string;
is_micro_trade?: boolean;
limit?: number;
offset?: number;
}) {
const qs = new URLSearchParams();
if (params?.ticker) qs.set('ticker', params.ticker);
if (params?.decision) qs.set('decision', params.decision);
if (params?.is_micro_trade !== undefined) qs.set('is_micro_trade', String(params.is_micro_trade));
if (params?.limit) qs.set('limit', String(params.limit));
if (params?.offset) qs.set('offset', String(params.offset));
const path = `/api/trading/decisions${qs.toString() ? '?' + qs : ''}`;
return useQuery<TradingDecision[]>({
queryKey: ['trading-decisions', params],
queryFn: () => apiGet<TradingDecision[]>('trading', path),
});
}
/** Fetch current performance metrics. */
export function useTradingMetrics() {
return useQuery<TradingMetrics>({
queryKey: ['trading-metrics'],
queryFn: () => apiGet<TradingMetrics>('trading', '/api/trading/metrics'),
});
}
/** Fetch historical daily portfolio snapshots. */
export function useTradingMetricsHistory() {
return useQuery<PortfolioSnapshot[]>({
queryKey: ['trading-metrics-history'],
queryFn: () => apiGet<PortfolioSnapshot[]>('trading', '/api/trading/metrics/history'),
});
}
/** Fetch trading engine configuration. */
export function useTradingConfig() {
return useQuery<TradingEngineConfig>({
queryKey: ['trading-config'],
queryFn: () => apiGet<TradingEngineConfig>('trading', '/api/trading/config'),
});
}
/** Fetch a backtest result by ID. */
export function useBacktestResult(id: string | undefined) {
return useQuery<BacktestResult>({
queryKey: ['backtest-result', id],
queryFn: () => apiGet<BacktestResult>('trading', `/api/trading/backtest/${id}`),
enabled: !!id,
});
}
/** Fetch notification preferences. */
export function useNotificationConfig() {
return useQuery<NotificationConfig>({
queryKey: ['notification-config'],
queryFn: () => apiGet<NotificationConfig>('trading', '/api/trading/notifications/config'),
});
}
/** Fetch notification history. */
export function useNotificationHistory() {
return useQuery<NotificationRecord[]>({
queryKey: ['notification-history'],
queryFn: () => apiGet<NotificationRecord[]>('trading', '/api/trading/notifications/history'),
});
}
// ---------------------------------------------------------------------------
// Mutation hooks
// ---------------------------------------------------------------------------
/** Update trading engine configuration. */
export function useUpdateTradingConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Partial<TradingEngineConfig>) =>
apiPut<TradingEngineConfig>('trading', '/api/trading/config', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['trading-config'] });
qc.invalidateQueries({ queryKey: ['trading-status'] });
},
});
}
/** Pause the trading engine. */
export function usePauseTradingEngine() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiPost<unknown>('trading', '/api/trading/pause', {}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['trading-status'] }),
});
}
/** Resume the trading engine. */
export function useResumeTradingEngine() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiPost<unknown>('trading', '/api/trading/resume', {}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['trading-status'] }),
});
}
/** Launch a new backtest run. */
export function useBacktestLaunch() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { start_date: string; end_date: string; initial_capital: number; risk_tier: string }) =>
apiPost<BacktestResult>('trading', '/api/trading/backtest', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['backtest-result'] }),
});
}
/** Update notification preferences. */
export function useUpdateNotificationConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Partial<NotificationConfig>) =>
apiPut<NotificationConfig>('trading', '/api/trading/notifications/config', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['notification-config'] }),
});
}
+2
View File
@@ -17,6 +17,7 @@ import {
LayoutDashboard,
List,
Globe,
BarChart3,
} from 'lucide-react';
interface NavItem {
@@ -37,6 +38,7 @@ const navItems: NavItem[] = [
{ to: '/orders', label: 'Orders', icon: <ShoppingCart size={18} />, group: 'Trading' },
{ to: '/positions', label: 'Positions', icon: <Wallet size={18} />, group: 'Trading' },
{ to: '/trading', label: 'Trading Controls', icon: <ShieldCheck size={18} />, group: 'Trading' },
{ to: '/trading/engine', label: 'Trading Engine', icon: <BarChart3 size={18} />, group: 'Trading' },
{ to: '/ops/pipeline', label: 'Pipeline', icon: <Activity size={18} />, group: 'Ops' },
{ to: '/ops/ingestion', label: 'Ingestion', icon: <Download size={18} />, group: 'Ops' },
{ to: '/ops/model', label: 'Model Perf', icon: <Cpu size={18} />, group: 'Ops' },
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react';
import { TradingOverview } from './trading/TradingOverview';
import { PortfolioComposition } from './trading/PortfolioComposition';
import { TradeHistory } from './trading/TradeHistory';
import { PerformanceCharts } from './trading/PerformanceCharts';
import { BacktestPanel } from './trading/BacktestPanel';
import { MicroTradingPanel } from './trading/MicroTradingPanel';
import { NotificationPreferences } from './trading/NotificationPreferences';
import { ErrorBoundary } from '../components/ui';
const TABS = [
{ id: 'overview', label: 'Overview' },
{ id: 'portfolio', label: 'Portfolio' },
{ id: 'history', label: 'Trade History' },
{ id: 'performance', label: 'Performance' },
{ id: 'backtest', label: 'Backtest' },
{ id: 'micro', label: 'Micro-Trading' },
{ id: 'notifications', label: 'Notifications' },
] as const;
type TabId = (typeof TABS)[number]['id'];
export function TradingEnginePage() {
const [activeTab, setActiveTab] = useState<TabId>('overview');
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold text-gray-100">Trading Engine</h1>
{/* Tab Navigation */}
<div className="flex gap-1 overflow-x-auto border-b border-surface-700 pb-px" role="tablist">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`whitespace-nowrap rounded-t-md px-3 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'border-b-2 border-brand-500 text-brand-300'
: 'text-gray-400 hover:bg-surface-800 hover:text-gray-200'
}`}
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={`panel-${tab.id}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<ErrorBoundary>
<div role="tabpanel" id={`panel-${activeTab}`}>
{activeTab === 'overview' && <TradingOverview />}
{activeTab === 'portfolio' && <PortfolioComposition />}
{activeTab === 'history' && <TradeHistory />}
{activeTab === 'performance' && <PerformanceCharts />}
{activeTab === 'backtest' && <BacktestPanel />}
{activeTab === 'micro' && <MicroTradingPanel />}
{activeTab === 'notifications' && <NotificationPreferences />}
</div>
</ErrorBoundary>
</div>
);
}
@@ -0,0 +1,170 @@
import { useState } from 'react';
import { useBacktestLaunch, useBacktestResult } from '../../api/tradingHooks';
import { Card, LoadingSpinner } from '../../components/ui';
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
} from 'recharts';
const RISK_TIERS = ['conservative', 'moderate', 'aggressive'];
function fmtUsd(v: number | null | undefined) {
if (v == null) return '—';
return `$${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function fmtPct(v: number | null | undefined) {
if (v == null) return '—';
return `${(v * 100).toFixed(2)}%`;
}
export function BacktestPanel() {
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [initialCapital, setInitialCapital] = useState('100000');
const [riskTier, setRiskTier] = useState('moderate');
const [backtestId, setBacktestId] = useState<string | undefined>(undefined);
const launch = useBacktestLaunch();
const { data: result, isLoading: resultLoading } = useBacktestResult(backtestId);
function handleLaunch() {
if (!startDate || !endDate) return;
launch.mutate(
{
start_date: startDate,
end_date: endDate,
initial_capital: Number(initialCapital),
risk_tier: riskTier,
},
{
onSuccess: (data) => setBacktestId(data.id),
},
);
}
const equityCurve = (result?.equity_curve ?? []).map((pt) => ({
date: new Date(pt.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
value: pt.portfolio_value,
}));
return (
<div className="space-y-4">
{/* Configuration Form */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Backtest Configuration</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label className="mb-1 block text-xs text-gray-500">Start Date</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs text-gray-500">End Date</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs text-gray-500">Initial Capital</label>
<input
type="number"
value={initialCapital}
onChange={(e) => setInitialCapital(e.target.value)}
className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none"
/>
</div>
<div>
<label className="mb-1 block text-xs text-gray-500">Risk Tier</label>
<select
value={riskTier}
onChange={(e) => setRiskTier(e.target.value)}
className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none"
>
{RISK_TIERS.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
</div>
<button
onClick={handleLaunch}
disabled={!startDate || !endDate || launch.isPending}
className="mt-3 rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{launch.isPending ? 'Launching…' : 'Run Backtest'}
</button>
{launch.isError && (
<p className="mt-2 text-xs text-red-400">Failed to launch backtest</p>
)}
</Card>
{/* Results */}
{resultLoading && <LoadingSpinner />}
{result && (
<>
{/* Summary Metrics */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Backtest Results</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
<MetricCard label="Total Return" value={fmtPct(result.total_return)} />
<MetricCard label="Sharpe Ratio" value={result.sharpe_ratio?.toFixed(2) ?? '—'} />
<MetricCard label="Max Drawdown" value={fmtPct(result.max_drawdown)} />
<MetricCard label="Win Rate" value={fmtPct(result.win_rate)} />
<MetricCard label="Profit Factor" value={result.profit_factor?.toFixed(2) ?? '—'} />
</div>
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
<span>Trades: {result.trade_count ?? 0}</span>
<span>Status: {result.status}</span>
<span>Capital: {fmtUsd(result.initial_capital)}</span>
</div>
</Card>
{/* Equity Curve */}
{equityCurve.length > 0 && (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Equity Curve</h2>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={equityCurve}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 11 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 11 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }}
labelStyle={{ color: '#9ca3af' }}
formatter={(value: number) => [fmtUsd(value), 'Portfolio Value']}
/>
<Line
type="monotone"
dataKey="value"
stroke="#6366f1"
strokeWidth={2}
dot={false}
name="Portfolio Value"
/>
</LineChart>
</ResponsiveContainer>
</Card>
)}
</>
)}
</div>
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="text-center">
<div className="text-sm font-bold text-gray-100">{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
);
}
@@ -0,0 +1,117 @@
import {
useTradingStatus,
useUpdateTradingConfig,
useTradingDecisions,
useTradingMetrics,
} from '../../api/tradingHooks';
import { Card, LoadingSpinner } from '../../components/ui';
function fmtUsd(v: number | null | undefined) {
if (v == null) return '—';
return `$${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function MicroTradingPanel() {
const { data: status, isLoading } = useTradingStatus();
const { data: metrics } = useTradingMetrics();
const { data: microDecisions } = useTradingDecisions({ is_micro_trade: true, limit: 20 });
const updateConfig = useUpdateTradingConfig();
if (isLoading) return <LoadingSpinner />;
if (!status) return <p className="text-sm text-gray-500">No trading status available</p>;
const microEnabled = status.micro_trading_enabled;
function handleToggle() {
updateConfig.mutate({ micro_trading_enabled: !microEnabled });
}
const todayMicroTrades = (microDecisions ?? []).filter((d) => {
const created = new Date(d.created_at);
const today = new Date();
return (
created.getFullYear() === today.getFullYear() &&
created.getMonth() === today.getMonth() &&
created.getDate() === today.getDate()
);
});
return (
<div className="space-y-4">
{/* Toggle */}
<Card>
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-gray-400">Micro-Trade Mode</h2>
<button
onClick={handleToggle}
disabled={updateConfig.isPending}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900 ${
microEnabled ? 'bg-brand-600' : 'bg-surface-700'
}`}
role="switch"
aria-checked={microEnabled}
aria-label="Toggle micro-trading"
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
microEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
<p className="mt-1 text-xs text-gray-500">
{microEnabled ? 'Micro-trading is enabled' : 'Micro-trading is disabled'}
</p>
</Card>
{/* Today's Stats */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatCard label="Today's Micro-Trades" value={String(todayMicroTrades.length)} />
<StatCard label="Daily P&L" value={fmtUsd(metrics?.daily_pnl)} />
<StatCard label="Win Rate" value={metrics?.win_rate != null ? `${(metrics.win_rate * 100).toFixed(1)}%` : '—'} />
</div>
{/* Recent Micro-Trades */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Recent Micro-Trades</h2>
{(microDecisions ?? []).length === 0 ? (
<p className="text-sm text-gray-500">No micro-trades recorded</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" role="grid">
<thead>
<tr className="border-b border-surface-700 text-left text-gray-500">
<th className="px-3 py-2">Ticker</th>
<th className="px-3 py-2">Decision</th>
<th className="px-3 py-2">Shares</th>
<th className="px-3 py-2">Size</th>
<th className="px-3 py-2">Date</th>
</tr>
</thead>
<tbody>
{(microDecisions ?? []).map((d) => (
<tr key={d.id} className="border-b border-surface-700/50">
<td className="px-3 py-2 font-mono font-semibold text-brand-300">{d.ticker}</td>
<td className="px-3 py-2 capitalize text-gray-300">{d.decision}</td>
<td className="px-3 py-2 text-gray-300">{d.computed_share_quantity ?? '—'}</td>
<td className="px-3 py-2 text-gray-300">{fmtUsd(d.computed_position_size)}</td>
<td className="px-3 py-2 text-xs text-gray-400">{new Date(d.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<Card className="text-center">
<div className="text-lg font-bold text-gray-100">{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</Card>
);
}
@@ -0,0 +1,163 @@
import { useState, useEffect } from 'react';
import {
useNotificationConfig,
useUpdateNotificationConfig,
useNotificationHistory,
} from '../../api/tradingHooks';
import type { NotificationRecord } from '../../api/tradingHooks';
import { Card, LoadingSpinner, StatusBadge } from '../../components/ui';
import { DataTable, type Column } from '../../components/DataTable';
const notifColumns: Column<NotificationRecord>[] = [
{
key: 'channel',
header: 'Channel',
render: (r) => <span className="capitalize">{r.channel}</span>,
},
{
key: 'event_type',
header: 'Event',
render: (r) => <span className="text-xs">{r.event_type}</span>,
},
{
key: 'message',
header: 'Message',
render: (r) => <span className="max-w-xs truncate text-xs text-gray-400">{r.message}</span>,
},
{
key: 'delivery_status',
header: 'Status',
render: (r) => <StatusBadge status={r.delivery_status} />,
},
{
key: 'created_at',
header: 'Sent',
render: (r) => <span className="text-xs">{new Date(r.created_at).toLocaleString()}</span>,
},
];
export function NotificationPreferences() {
const { data: config, isLoading } = useNotificationConfig();
const { data: history } = useNotificationHistory();
const updateConfig = useUpdateNotificationConfig();
const [smsEnabled, setSmsEnabled] = useState(false);
const [emailEnabled, setEmailEnabled] = useState(false);
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
useEffect(() => {
if (config) {
setSmsEnabled(config.sms_enabled);
setEmailEnabled(config.email_enabled);
setPhone(config.phone_number ?? '');
setEmail(config.email_recipient ?? '');
}
}, [config]);
function handleSave() {
updateConfig.mutate({
sms_enabled: smsEnabled,
email_enabled: emailEnabled,
phone_number: phone || null,
email_recipient: email || null,
});
}
if (isLoading) return <LoadingSpinner />;
return (
<div className="space-y-4">
{/* Channel Toggles */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Notification Channels</h2>
<div className="space-y-4">
{/* SMS */}
<div className="flex items-start gap-4">
<button
onClick={() => setSmsEnabled(!smsEnabled)}
className={`relative mt-0.5 inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900 ${
smsEnabled ? 'bg-brand-600' : 'bg-surface-700'
}`}
role="switch"
aria-checked={smsEnabled}
aria-label="Toggle SMS notifications"
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
smsEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
<div className="flex-1">
<div className="text-sm text-gray-300">SMS Notifications</div>
<input
type="tel"
placeholder="Phone number…"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="mt-1 w-full max-w-xs rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
aria-label="Phone number"
/>
</div>
</div>
{/* Email */}
<div className="flex items-start gap-4">
<button
onClick={() => setEmailEnabled(!emailEnabled)}
className={`relative mt-0.5 inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900 ${
emailEnabled ? 'bg-brand-600' : 'bg-surface-700'
}`}
role="switch"
aria-checked={emailEnabled}
aria-label="Toggle email notifications"
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
emailEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
<div className="flex-1">
<div className="text-sm text-gray-300">Email Notifications</div>
<input
type="email"
placeholder="Email address…"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 w-full max-w-xs rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
aria-label="Email address"
/>
</div>
</div>
</div>
<button
onClick={handleSave}
disabled={updateConfig.isPending}
className="mt-4 rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{updateConfig.isPending ? 'Saving…' : 'Save Preferences'}
</button>
{updateConfig.isSuccess && (
<span className="ml-3 text-xs text-green-400">Saved</span>
)}
</Card>
{/* Notification History */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">
Recent Notifications ({history?.length ?? 0})
</h2>
<DataTable<NotificationRecord>
data={history ?? []}
columns={notifColumns}
keyField="id"
pageSize={15}
emptyMessage="No notifications yet"
/>
</Card>
</div>
);
}
@@ -0,0 +1,115 @@
import { useMemo } from 'react';
import { useTradingMetricsHistory } from '../../api/tradingHooks';
import { Card, LoadingSpinner } from '../../components/ui';
import {
LineChart, Line, BarChart, Bar, AreaChart, Area,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Cell,
} from 'recharts';
const tooltipStyle = {
backgroundColor: '#1e293b',
border: '1px solid #334155',
borderRadius: 8,
};
export function PerformanceCharts() {
const { data: snapshots, isLoading } = useTradingMetricsHistory();
const chartData = useMemo(() => {
if (!snapshots) return [];
return snapshots.map((s) => ({
date: new Date(s.snapshot_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
portfolioValue: s.portfolio_value,
cumulativeReturn: (s.cumulative_return ?? 0) * 100,
dailyReturn: (s.daily_return ?? 0) * 100,
drawdown: (s.current_drawdown_pct ?? 0) * 100,
maxDrawdown: (s.max_drawdown ?? 0) * 100,
}));
}, [snapshots]);
if (isLoading) return <LoadingSpinner />;
if (chartData.length === 0) {
return (
<Card>
<p className="text-sm text-gray-500">No performance history available yet</p>
</Card>
);
}
return (
<div className="space-y-4">
{/* Cumulative P&L */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Cumulative P&amp;L</h2>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 11 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 11 }} tickFormatter={(v) => `${v.toFixed(1)}%`} />
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: '#9ca3af' }}
formatter={(value: number) => [`${value.toFixed(2)}%`, 'Cumulative Return']}
/>
<Line
type="monotone"
dataKey="cumulativeReturn"
stroke="#6366f1"
strokeWidth={2}
dot={false}
name="Cumulative Return"
/>
</LineChart>
</ResponsiveContainer>
</Card>
{/* Daily Returns */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Daily Returns</h2>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 11 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 11 }} tickFormatter={(v) => `${v.toFixed(1)}%`} />
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: '#9ca3af' }}
formatter={(value: number) => [`${value.toFixed(2)}%`, 'Daily Return']}
/>
<Bar dataKey="dailyReturn" name="Daily Return">
{chartData.map((entry, i) => (
<Cell key={i} fill={entry.dailyReturn >= 0 ? '#22c55e' : '#ef4444'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
{/* Drawdown */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Drawdown</h2>
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 11 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 11 }} tickFormatter={(v) => `${v.toFixed(1)}%`} />
<Tooltip
contentStyle={tooltipStyle}
labelStyle={{ color: '#9ca3af' }}
formatter={(value: number) => [`${value.toFixed(2)}%`, 'Drawdown']}
/>
<Area
type="monotone"
dataKey="drawdown"
stroke="#ef4444"
fill="#ef444433"
name="Current Drawdown"
/>
</AreaChart>
</ResponsiveContainer>
</Card>
</div>
);
}
@@ -0,0 +1,125 @@
import { useMemo } from 'react';
import { useTradingStatus } from '../../api/tradingHooks';
import { Card, LoadingSpinner } from '../../components/ui';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
const COLORS = [
'#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4',
'#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#64748b',
];
interface Position {
ticker: string;
entry_price: number;
current_price: number;
unrealized_pnl: number;
stop_loss: number | null;
take_profit: number | null;
sector: string | null;
quantity: number;
}
function fmtUsd(v: number | null | undefined) {
if (v == null) return '—';
return `$${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function pnlColor(v: number | null | undefined) {
if (v == null) return 'text-gray-400';
return v >= 0 ? 'text-green-400' : 'text-red-400';
}
export function PortfolioComposition() {
const { data: status, isLoading } = useTradingStatus();
// Extract positions from the status — the API may embed them or we derive from open_position_count
// For now, we use a typed cast since the status object may carry positions in an extended response
const positions: Position[] = useMemo(() => {
if (!status) return [];
const raw = (status as Record<string, unknown>)['positions'];
if (Array.isArray(raw)) return raw as Position[];
return [];
}, [status]);
const sectorData = useMemo(() => {
const map = new Map<string, number>();
for (const p of positions) {
const sector = p.sector ?? 'Unknown';
const value = (p.current_price ?? 0) * (p.quantity ?? 0);
map.set(sector, (map.get(sector) ?? 0) + value);
}
return Array.from(map.entries()).map(([name, value]) => ({ name, value }));
}, [positions]);
if (isLoading) return <LoadingSpinner />;
return (
<div className="space-y-4">
{/* Positions Table */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Current Positions</h2>
{positions.length === 0 ? (
<p className="text-sm text-gray-500">No open positions</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" role="grid">
<thead>
<tr className="border-b border-surface-700 text-left text-gray-500">
<th className="px-3 py-2">Ticker</th>
<th className="px-3 py-2">Entry</th>
<th className="px-3 py-2">Current</th>
<th className="px-3 py-2">Unrealized P&amp;L</th>
<th className="px-3 py-2">Stop-Loss</th>
<th className="px-3 py-2">Take-Profit</th>
<th className="px-3 py-2">Sector</th>
</tr>
</thead>
<tbody>
{positions.map((p) => (
<tr key={p.ticker} className="border-b border-surface-700/50">
<td className="px-3 py-2 font-mono font-semibold text-brand-300">{p.ticker}</td>
<td className="px-3 py-2 text-gray-300">{fmtUsd(p.entry_price)}</td>
<td className="px-3 py-2 text-gray-300">{fmtUsd(p.current_price)}</td>
<td className={`px-3 py-2 ${pnlColor(p.unrealized_pnl)}`}>{fmtUsd(p.unrealized_pnl)}</td>
<td className="px-3 py-2 text-gray-300">{fmtUsd(p.stop_loss)}</td>
<td className="px-3 py-2 text-gray-300">{fmtUsd(p.take_profit)}</td>
<td className="px-3 py-2 text-gray-400">{p.sector ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{/* Sector Allocation Pie Chart */}
{sectorData.length > 0 && (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Sector Allocation</h2>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={sectorData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={100}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
>
{sectorData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }}
labelStyle={{ color: '#9ca3af' }}
formatter={(value: number) => fmtUsd(value)}
/>
</PieChart>
</ResponsiveContainer>
</Card>
)}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { useTradingDecisions } from '../../api/tradingHooks';
import type { TradingDecision } from '../../api/tradingHooks';
import { Card, LoadingSpinner } from '../../components/ui';
import { DataTable, type Column } from '../../components/DataTable';
function fmtUsd(v: number | null | undefined) {
if (v == null) return '—';
return `$${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function pnlColor(v: number | null | undefined) {
if (v == null) return 'text-gray-400';
return v >= 0 ? 'text-green-400' : 'text-red-400';
}
const PAGE_SIZE = 25;
const columns: Column<TradingDecision>[] = [
{
key: 'ticker',
header: 'Ticker',
className: 'font-mono font-semibold text-brand-300',
},
{
key: 'decision',
header: 'Decision',
render: (r) => <span className="capitalize">{r.decision}</span>,
},
{
key: 'computed_share_quantity',
header: 'Shares',
render: (r) => <span>{r.computed_share_quantity ?? '—'}</span>,
},
{
key: 'computed_position_size',
header: 'Position Size',
render: (r) => <span>{fmtUsd(r.computed_position_size)}</span>,
},
{
key: 'risk_tier_at_decision',
header: 'Risk Tier',
render: (r) => <span className="capitalize">{r.risk_tier_at_decision}</span>,
},
{
key: 'portfolio_heat_at_decision',
header: 'Heat',
render: (r) => (
<span className={pnlColor(r.portfolio_heat_at_decision)}>
{r.portfolio_heat_at_decision != null ? `${(r.portfolio_heat_at_decision * 100).toFixed(1)}%` : '—'}
</span>
),
},
{
key: 'is_micro_trade',
header: 'Micro',
render: (r) => <span>{r.is_micro_trade ? 'Yes' : 'No'}</span>,
},
{
key: 'created_at',
header: 'Date',
render: (r) => <span className="text-xs">{new Date(r.created_at).toLocaleString()}</span>,
},
];
export function TradeHistory() {
const [tickerFilter, setTickerFilter] = useState('');
const [page, setPage] = useState(0);
const { data, isLoading } = useTradingDecisions({
ticker: tickerFilter || undefined,
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
});
if (isLoading) return <LoadingSpinner />;
return (
<div className="space-y-4">
<Card>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-medium text-gray-400">Trade History</h2>
<input
type="text"
placeholder="Filter by ticker…"
value={tickerFilter}
onChange={(e) => {
setTickerFilter(e.target.value.toUpperCase());
setPage(0);
}}
className="w-32 rounded-md border border-surface-700 bg-surface-950 px-2 py-1 text-xs text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
aria-label="Filter by ticker"
/>
</div>
<DataTable<TradingDecision>
data={data ?? []}
columns={columns}
keyField="id"
pageSize={PAGE_SIZE}
emptyMessage="No trading decisions found"
/>
{/* Server-side pagination controls */}
<div className="mt-2 flex items-center justify-end gap-2 text-xs text-gray-500">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded px-2 py-1 hover:bg-surface-800 disabled:opacity-30"
aria-label="Previous page"
>
Prev
</button>
<span>Page {page + 1}</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={(data?.length ?? 0) < PAGE_SIZE}
className="rounded px-2 py-1 hover:bg-surface-800 disabled:opacity-30"
aria-label="Next page"
>
Next
</button>
</div>
</Card>
</div>
);
}
@@ -0,0 +1,193 @@
import { useState } from 'react';
import {
useTradingStatus,
usePauseTradingEngine,
useResumeTradingEngine,
useUpdateTradingConfig,
useTradingMetrics,
} from '../../api/tradingHooks';
import { Card, LoadingSpinner, StatusBadge } from '../../components/ui';
const RISK_TIERS = ['conservative', 'moderate', 'aggressive'];
function fmtUsd(v: number | null | undefined) {
if (v == null) return '—';
return `$${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function fmtPct(v: number | null | undefined) {
if (v == null) return '—';
return `${(v * 100).toFixed(2)}%`;
}
function cooldownRemaining(expires: string | null | undefined): string {
if (!expires) return '';
const ms = new Date(expires).getTime() - Date.now();
if (ms <= 0) return 'expired';
const mins = Math.ceil(ms / 60000);
return mins >= 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`;
}
export function TradingOverview() {
const { data: status, isLoading } = useTradingStatus();
const { data: metrics } = useTradingMetrics();
const pause = usePauseTradingEngine();
const resume = useResumeTradingEngine();
const updateConfig = useUpdateTradingConfig();
const [selectedTier, setSelectedTier] = useState<string | null>(null);
if (isLoading) return <LoadingSpinner />;
if (!status) return <p className="text-gray-500">No trading status available</p>;
const effectiveTier = selectedTier ?? status.risk_tier;
function handleTierChange(tier: string) {
setSelectedTier(tier);
updateConfig.mutate({ risk_tier: tier });
}
return (
<div className="space-y-4">
{/* Controls */}
<Card>
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-gray-400">Engine Controls</h2>
<div className="flex items-center gap-2">
{status.paused ? (
<button
onClick={() => resume.mutate()}
disabled={resume.isPending}
className="rounded-md bg-green-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-600 disabled:opacity-50"
>
Resume
</button>
) : (
<button
onClick={() => pause.mutate()}
disabled={pause.isPending}
className="rounded-md bg-yellow-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-yellow-600 disabled:opacity-50"
>
Pause
</button>
)}
</div>
</div>
<div className="mt-3 flex items-center gap-4">
<StatusBadge status={status.paused ? 'paused' : status.enabled ? 'active' : 'disabled'} />
<span className="text-xs text-gray-500">
{status.open_position_count} open positions
</span>
{status.last_decision_at && (
<span className="text-xs text-gray-500">
Last decision: {new Date(status.last_decision_at).toLocaleString()}
</span>
)}
</div>
</Card>
{/* Risk Tier & Circuit Breaker */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Risk Tier</h2>
<select
value={effectiveTier}
onChange={(e) => handleTierChange(e.target.value)}
className="rounded-md border border-surface-700 bg-surface-950 px-3 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none"
aria-label="Risk tier selector"
>
{RISK_TIERS.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</Card>
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Circuit Breaker</h2>
<div className="flex items-center gap-3">
<StatusBadge status={status.circuit_breaker_active ? 'active' : 'inactive'} />
{status.circuit_breaker_active && (
<div className="text-xs text-gray-400">
{status.circuit_breaker_trigger_type && (
<span>Trigger: {status.circuit_breaker_trigger_type}</span>
)}
{status.circuit_breaker_cooldown_expires && (
<span className="ml-2">
Cooldown: {cooldownRemaining(status.circuit_breaker_cooldown_expires)}
</span>
)}
</div>
)}
</div>
</Card>
</div>
{/* Pool Balances & Portfolio Heat */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Active Pool" value={fmtUsd(status.active_pool)} />
<StatCard label="Reserve Pool" value={fmtUsd(status.reserve_pool)} />
<StatCard label="Portfolio Value" value={fmtUsd(status.portfolio_value)} />
<StatCard label="Portfolio Heat" value={fmtPct(status.portfolio_heat)} />
</div>
{/* Portfolio Heat Gauge */}
<Card>
<h2 className="mb-2 text-sm font-medium text-gray-400">Portfolio Heat</h2>
<div className="h-3 w-full rounded-full bg-surface-700">
<div
className={`h-full rounded-full transition-all ${
(status.portfolio_heat ?? 0) > 0.8
? 'bg-red-500'
: (status.portfolio_heat ?? 0) > 0.5
? 'bg-yellow-500'
: 'bg-green-500'
}`}
style={{ width: `${Math.min(100, (status.portfolio_heat ?? 0) * 100)}%` }}
/>
</div>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>0%</span>
<span>{((status.portfolio_heat ?? 0) * 100).toFixed(1)}%</span>
<span>100%</span>
</div>
</Card>
{/* 24h P&L Summary */}
{metrics && (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Last 24h P&amp;L Summary</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<MiniStat label="Daily P&L" value={fmtUsd(metrics.daily_pnl)} color={pnlColor(metrics.daily_pnl)} />
<MiniStat label="Unrealized" value={fmtUsd(metrics.unrealized_pnl)} color={pnlColor(metrics.unrealized_pnl)} />
<MiniStat label="Win Rate" value={fmtPct(metrics.win_rate)} />
<MiniStat label="Profit Factor" value={metrics.profit_factor?.toFixed(2) ?? '—'} />
</div>
</Card>
)}
</div>
);
}
function pnlColor(v: number | null | undefined) {
if (v == null) return 'text-gray-300';
return v >= 0 ? 'text-green-400' : 'text-red-400';
}
function StatCard({ label, value }: { label: string; value: string }) {
return (
<Card className="text-center">
<div className="text-lg font-bold text-gray-100">{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</Card>
);
}
function MiniStat({ label, value, color = 'text-gray-100' }: { label: string; value: string; color?: string }) {
return (
<div>
<div className={`text-sm font-semibold ${color}`}>{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
);
}
+7
View File
@@ -19,6 +19,7 @@ import { OrdersPage } from './pages/Orders';
import { OrderDetailPage } from './pages/OrderDetail';
import { PositionsPage } from './pages/Positions';
import { TradingPage } from './pages/Trading';
import { TradingEnginePage } from './pages/TradingEngine';
import { OpsPipelinePage } from './pages/OpsPipeline';
import { OpsIngestionPage } from './pages/OpsIngestion';
import { OpsModelPage } from './pages/OpsModel';
@@ -109,6 +110,11 @@ const tradingRoute = createRoute({
path: '/trading',
component: TradingPage,
});
const tradingEngineRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/trading/engine',
component: TradingEnginePage,
});
const opsPipelineRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ops/pipeline',
@@ -166,6 +172,7 @@ const routeTree = rootRoute.addChildren([
orderDetailRoute,
positionsRoute,
tradingRoute,
tradingEngineRoute,
opsPipelineRoute,
opsIngestionRoute,
opsModelRoute,