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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] }),
|
||||
});
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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&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&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>
|
||||
);
|
||||
}
|
||||
@@ -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&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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user