initial charts / backtest

This commit is contained in:
Boki 2025-07-02 19:58:43 -04:00
parent 11c24b2280
commit 1b9010ebf4
37 changed files with 3888 additions and 23 deletions

View file

@ -3,6 +3,8 @@ import { DashboardPage } from '@/features/dashboard';
import { ExchangesPage } from '@/features/exchanges';
import { MonitoringPage } from '@/features/monitoring';
import { PipelinePage } from '@/features/pipeline';
import { BacktestPage } from '@/features/backtest';
import { ChartPage } from '@/features/charts';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
export function App() {
@ -12,6 +14,7 @@ export function App() {
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="charts" element={<ChartPage />} />
<Route path="exchanges" element={<ExchangesPage />} />
<Route
path="portfolio"
@ -25,6 +28,7 @@ export function App() {
path="analytics"
element={<div className="p-4">Analytics Page - Coming Soon</div>}
/>
<Route path="backtest" element={<BacktestPage />} />
<Route path="settings" element={<div className="p-4">Settings Page - Coming Soon</div>} />
<Route path="system/monitoring" element={<MonitoringPage />} />
<Route path="system/pipeline" element={<PipelinePage />} />

View file

@ -0,0 +1,69 @@
import { BacktestConfiguration } from './components/BacktestConfiguration';
import { BacktestResults } from './components/BacktestResults';
import { BacktestControls } from './components/BacktestControls';
import { useBacktest } from './hooks/useBacktest';
export function BacktestPage() {
const {
config,
status,
results,
currentTime,
error,
isLoading,
handleConfigSubmit,
handleStart,
handlePause,
handleResume,
handleStop,
handleStep,
} = useBacktest();
return (
<div className="flex flex-col h-full space-y-6">
<div className="flex-shrink-0">
<h1 className="text-lg font-bold text-text-primary mb-2">Backtest Strategy</h1>
<p className="text-text-secondary mb-6 text-sm">
Test your trading strategies against historical data to evaluate performance and risk.
</p>
</div>
{error && (
<div className="bg-error/10 border border-error text-error px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
<div className="lg:col-span-1 space-y-4">
<BacktestConfiguration
onSubmit={handleConfigSubmit}
disabled={status === 'running' || isLoading}
/>
{config && (
<BacktestControls
status={status}
onStart={handleStart}
onPause={handlePause}
onResume={handleResume}
onStop={handleStop}
onStep={handleStep}
currentTime={currentTime}
startTime={config.startDate.getTime()}
endTime={config.endDate.getTime()}
/>
)}
</div>
<div className="lg:col-span-2">
<BacktestResults
status={status}
results={results}
currentTime={currentTime}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,257 @@
import { useState } from 'react';
import type { BacktestConfig } from '../types';
interface BacktestConfigurationProps {
onSubmit: (config: BacktestConfig) => void;
disabled?: boolean;
}
export function BacktestConfiguration({ onSubmit, disabled }: BacktestConfigurationProps) {
const [formData, setFormData] = useState<BacktestConfig>({
name: '',
startDate: new Date(new Date().setMonth(new Date().getMonth() - 6)), // 6 months ago
endDate: new Date(),
initialCapital: 100000,
symbols: [],
strategy: 'momentum',
speedMultiplier: 1,
commission: 0.001, // 0.1%
slippage: 0.0005, // 0.05%
});
const [symbolInput, setSymbolInput] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (formData.symbols.length === 0) {
alert('Please add at least one symbol');
return;
}
onSubmit(formData);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'number' ? parseFloat(value) :
type === 'date' ? new Date(value) : value
}));
};
const handleAddSymbol = () => {
const symbol = symbolInput.trim().toUpperCase();
if (symbol && !formData.symbols.includes(symbol)) {
setFormData(prev => ({
...prev,
symbols: [...prev.symbols, symbol]
}));
setSymbolInput('');
}
};
const handleRemoveSymbol = (symbol: string) => {
setFormData(prev => ({
...prev,
symbols: prev.symbols.filter(s => s !== symbol)
}));
};
const formatDate = (date: Date) => {
return date.toISOString().split('T')[0];
};
return (
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
<h2 className="text-base font-medium text-text-primary mb-4">Configuration</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Backtest Name
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="My Strategy Backtest"
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Start Date
</label>
<input
type="date"
name="startDate"
value={formatDate(formData.startDate)}
onChange={handleChange}
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
End Date
</label>
<input
type="date"
name="endDate"
value={formatDate(formData.endDate)}
onChange={handleChange}
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Initial Capital ($)
</label>
<input
type="number"
name="initialCapital"
value={formData.initialCapital}
onChange={handleChange}
min="1000"
step="1000"
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Symbols
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={symbolInput}
onChange={(e) => setSymbolInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddSymbol())}
placeholder="AAPL"
className="flex-1 px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
/>
<button
type="button"
onClick={handleAddSymbol}
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors disabled:opacity-50"
disabled={disabled}
>
Add
</button>
</div>
<div className="flex flex-wrap gap-2">
{formData.symbols.map(symbol => (
<span
key={symbol}
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-500/10 text-primary-400 rounded-md text-sm"
>
{symbol}
<button
type="button"
onClick={() => handleRemoveSymbol(symbol)}
className="text-primary-400 hover:text-primary-300"
disabled={disabled}
>
×
</button>
</span>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Strategy
</label>
<select
name="strategy"
value={formData.strategy}
onChange={handleChange}
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
>
<option value="momentum">Momentum</option>
<option value="mean-reversion">Mean Reversion</option>
<option value="pairs-trading">Pairs Trading</option>
<option value="custom">Custom</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Commission (%)
</label>
<input
type="number"
name="commission"
value={formData.commission * 100}
onChange={(e) => setFormData(prev => ({ ...prev, commission: parseFloat(e.target.value) / 100 }))}
min="0"
step="0.01"
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Slippage (%)
</label>
<input
type="number"
name="slippage"
value={formData.slippage * 100}
onChange={(e) => setFormData(prev => ({ ...prev, slippage: parseFloat(e.target.value) / 100 }))}
min="0"
step="0.01"
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">
Speed Multiplier
</label>
<input
type="number"
name="speedMultiplier"
value={formData.speedMultiplier}
onChange={handleChange}
min="0.1"
max="1000"
step="0.1"
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
disabled={disabled}
/>
<p className="text-xs text-text-muted mt-1">1x = real-time, 10x = 10x faster</p>
</div>
<button
type="submit"
className="w-full px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={disabled}
>
Configure Backtest
</button>
</form>
</div>
);
}

View file

@ -0,0 +1,147 @@
import {
ArrowPathIcon,
ForwardIcon,
PauseIcon,
PlayIcon,
StopIcon,
} from '@heroicons/react/24/solid';
import type { BacktestStatus } from '../types';
interface BacktestControlsProps {
status: BacktestStatus;
onStart: () => void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
onStep: () => void;
currentTime: number | null;
startTime: number;
endTime: number;
}
export function BacktestControls({
status,
onStart,
onPause,
onResume,
onStop,
onStep,
currentTime,
startTime,
endTime,
}: BacktestControlsProps) {
const progress = currentTime
? ((currentTime - startTime) / (endTime - startTime)) * 100
: 0;
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
return (
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
<h2 className="text-base font-medium text-text-primary mb-4">Controls</h2>
<div className="space-y-4">
<div className="flex gap-2">
{status === 'configured' || status === 'stopped' ? (
<button
onClick={onStart}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-success text-white rounded-md text-sm font-medium hover:bg-success/90 transition-colors"
>
<PlayIcon className="w-4 h-4" />
Start
</button>
) : status === 'running' ? (
<button
onClick={onPause}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-warning text-white rounded-md text-sm font-medium hover:bg-warning/90 transition-colors"
>
<PauseIcon className="w-4 h-4" />
Pause
</button>
) : status === 'paused' ? (
<button
onClick={onResume}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-success text-white rounded-md text-sm font-medium hover:bg-success/90 transition-colors"
>
<PlayIcon className="w-4 h-4" />
Resume
</button>
) : null}
{(status === 'running' || status === 'paused') && (
<button
onClick={onStop}
className="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-error text-white rounded-md text-sm font-medium hover:bg-error/90 transition-colors"
>
<StopIcon className="w-4 h-4" />
Stop
</button>
)}
{status === 'paused' && (
<button
onClick={onStep}
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
>
<ForwardIcon className="w-4 h-4" />
Step
</button>
)}
</div>
{status !== 'idle' && status !== 'configured' && (
<>
<div>
<div className="flex justify-between text-sm text-text-secondary mb-1">
<span>Progress</span>
<span>{progress.toFixed(1)}%</span>
</div>
<div className="w-full bg-background rounded-full h-2 overflow-hidden">
<div
className="bg-primary-500 h-2 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-secondary">Status</span>
<span className={`text-text-primary font-medium ${
status === 'running' ? 'text-success' :
status === 'paused' ? 'text-warning' :
status === 'completed' ? 'text-primary-400' :
status === 'error' ? 'text-error' :
'text-text-muted'
}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</div>
{currentTime && (
<div className="flex justify-between">
<span className="text-text-secondary">Current Time</span>
<span className="text-text-primary text-xs">
{formatTime(currentTime)}
</span>
</div>
)}
</div>
</>
)}
{status === 'completed' && (
<button
onClick={onStart}
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
>
<ArrowPathIcon className="w-4 h-4" />
Run Again
</button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,141 @@
import type { BacktestResult, BacktestStatus } from '../types';
import { MetricsCard } from './MetricsCard';
import { PositionsTable } from './PositionsTable';
import { TradeLog } from './TradeLog';
interface BacktestResultsProps {
status: BacktestStatus;
results: BacktestResult | null;
currentTime: number | null;
}
export function BacktestResults({ status, results, currentTime }: BacktestResultsProps) {
if (status === 'idle') {
return (
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
<div className="text-center">
<h3 className="text-lg font-medium text-text-primary mb-2">
Configure Your Backtest
</h3>
<p className="text-sm text-text-secondary">
Set up your strategy parameters and click "Configure Backtest" to begin.
</p>
</div>
</div>
);
}
if (status === 'configured') {
return (
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
<div className="text-center">
<h3 className="text-lg font-medium text-text-primary mb-2">
Ready to Start
</h3>
<p className="text-sm text-text-secondary">
Click the "Start" button to begin backtesting your strategy.
</p>
</div>
</div>
);
}
if (status === 'running' && !results) {
return (
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-primary-500 mx-auto mb-4"></div>
<h3 className="text-lg font-medium text-text-primary mb-2">
Running Backtest...
</h3>
<p className="text-sm text-text-secondary">
Processing historical data and executing trades.
</p>
</div>
</div>
);
}
if (!results) {
return (
<div className="bg-surface-secondary p-8 rounded-lg border border-border h-full flex items-center justify-center">
<div className="text-center">
<h3 className="text-lg font-medium text-text-primary mb-2">
No Results Yet
</h3>
<p className="text-sm text-text-secondary">
Results will appear here once the backtest is complete.
</p>
</div>
</div>
);
}
return (
<div className="space-y-6 h-full overflow-y-auto">
{/* Metrics Overview */}
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
<MetricsCard
title="Total Return"
value={`${results.metrics.totalReturn >= 0 ? '+' : ''}${results.metrics.totalReturn.toFixed(2)}%`}
trend={results.metrics.totalReturn >= 0 ? 'up' : 'down'}
/>
<MetricsCard
title="Sharpe Ratio"
value={results.metrics.sharpeRatio.toFixed(2)}
trend={results.metrics.sharpeRatio >= 1 ? 'up' : 'down'}
/>
<MetricsCard
title="Max Drawdown"
value={`${results.metrics.maxDrawdown.toFixed(2)}%`}
trend="down"
/>
<MetricsCard
title="Win Rate"
value={`${results.metrics.winRate.toFixed(1)}%`}
trend={results.metrics.winRate >= 50 ? 'up' : 'down'}
/>
<MetricsCard
title="Total Trades"
value={results.metrics.totalTrades.toString()}
/>
<MetricsCard
title="Profitable Trades"
value={results.metrics.profitableTrades.toString()}
/>
</div>
{/* Performance Chart Placeholder */}
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
<h3 className="text-base font-medium text-text-primary mb-4">
Portfolio Performance
</h3>
<div className="h-64 bg-background rounded border border-border flex items-center justify-center">
<p className="text-sm text-text-muted">
Performance chart will be displayed here (requires recharts)
</p>
</div>
</div>
{/* Positions Table */}
{results.positions.length > 0 && (
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
<h3 className="text-base font-medium text-text-primary mb-4">
Current Positions
</h3>
<PositionsTable positions={results.positions} />
</div>
)}
{/* Trade Log */}
{results.trades.length > 0 && (
<div className="bg-surface-secondary p-4 rounded-lg border border-border">
<h3 className="text-base font-medium text-text-primary mb-4">
Trade History
</h3>
<TradeLog trades={results.trades} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,38 @@
import { ArrowUpIcon, ArrowDownIcon } from '@heroicons/react/24/solid';
interface MetricsCardProps {
title: string;
value: string;
trend?: 'up' | 'down';
subtitle?: string;
}
export function MetricsCard({ title, value, trend, subtitle }: MetricsCardProps) {
return (
<div className="bg-background p-4 rounded-lg border border-border">
<h4 className="text-sm font-medium text-text-secondary mb-1">{title}</h4>
<div className="flex items-center gap-2">
<p className={`text-2xl font-bold ${
trend === 'up' ? 'text-success' :
trend === 'down' ? 'text-error' :
'text-text-primary'
}`}>
{value}
</p>
{trend && (
<span className={`inline-flex ${
trend === 'up' ? 'text-success' : 'text-error'
}`}>
{trend === 'up' ?
<ArrowUpIcon className="w-4 h-4" /> :
<ArrowDownIcon className="w-4 h-4" />
}
</span>
)}
</div>
{subtitle && (
<p className="text-xs text-text-muted mt-1">{subtitle}</p>
)}
</div>
);
}

View file

@ -0,0 +1,67 @@
import type { Position } from '../types';
interface PositionsTableProps {
positions: Position[];
}
export function PositionsTable({ positions }: PositionsTableProps) {
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const formatPnl = (value: number) => {
const formatted = formatCurrency(Math.abs(value));
return value >= 0 ? `+${formatted}` : `-${formatted}`;
};
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-2 font-medium text-text-secondary">Symbol</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Quantity</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Avg Price</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Current</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">P&L</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Unrealized</th>
</tr>
</thead>
<tbody>
{positions.map((position) => {
const totalPnl = position.realizedPnl + position.unrealizedPnl;
return (
<tr key={position.symbol} className="border-b border-border hover:bg-surface-tertiary">
<td className="py-2 px-2 font-medium text-text-primary">{position.symbol}</td>
<td className="text-right py-2 px-2 text-text-primary">
{position.quantity.toLocaleString()}
</td>
<td className="text-right py-2 px-2 text-text-primary">
{formatCurrency(position.averagePrice)}
</td>
<td className="text-right py-2 px-2 text-text-primary">
{formatCurrency(position.currentPrice)}
</td>
<td className={`text-right py-2 px-2 font-medium ${
totalPnl >= 0 ? 'text-success' : 'text-error'
}`}>
{formatPnl(totalPnl)}
</td>
<td className={`text-right py-2 px-2 ${
position.unrealizedPnl >= 0 ? 'text-success' : 'text-error'
}`}>
{formatPnl(position.unrealizedPnl)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,90 @@
import type { Trade } from '../types';
interface TradeLogProps {
trades: Trade[];
}
export function TradeLog({ trades }: TradeLogProps) {
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
};
const formatTime = (timestamp: string) => {
return new Date(timestamp).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
// Show latest trades first
const sortedTrades = [...trades].reverse();
return (
<div className="overflow-x-auto max-h-96">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-surface-secondary">
<tr className="border-b border-border">
<th className="text-left py-2 px-2 font-medium text-text-secondary">Time</th>
<th className="text-left py-2 px-2 font-medium text-text-secondary">Symbol</th>
<th className="text-center py-2 px-2 font-medium text-text-secondary">Side</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Quantity</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Price</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Value</th>
<th className="text-right py-2 px-2 font-medium text-text-secondary">Comm.</th>
{trades.some(t => t.pnl !== undefined) && (
<th className="text-right py-2 px-2 font-medium text-text-secondary">P&L</th>
)}
</tr>
</thead>
<tbody>
{sortedTrades.map((trade) => {
const tradeValue = trade.quantity * trade.price;
return (
<tr key={trade.id} className="border-b border-border hover:bg-surface-tertiary">
<td className="py-2 px-2 text-text-muted text-xs">
{formatTime(trade.timestamp)}
</td>
<td className="py-2 px-2 font-medium text-text-primary">{trade.symbol}</td>
<td className="text-center py-2 px-2">
<span className={`inline-flex px-2 py-0.5 text-xs font-medium rounded ${
trade.side === 'buy'
? 'bg-success/10 text-success'
: 'bg-error/10 text-error'
}`}>
{trade.side.toUpperCase()}
</span>
</td>
<td className="text-right py-2 px-2 text-text-primary">
{trade.quantity.toLocaleString()}
</td>
<td className="text-right py-2 px-2 text-text-primary">
{formatCurrency(trade.price)}
</td>
<td className="text-right py-2 px-2 text-text-primary">
{formatCurrency(tradeValue)}
</td>
<td className="text-right py-2 px-2 text-text-muted">
{formatCurrency(trade.commission)}
</td>
{trade.pnl !== undefined && (
<td className={`text-right py-2 px-2 font-medium ${
trade.pnl >= 0 ? 'text-success' : 'text-error'
}`}>
{trade.pnl >= 0 ? '+' : ''}{formatCurrency(trade.pnl)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,6 @@
export { BacktestConfiguration } from './BacktestConfiguration';
export { BacktestControls } from './BacktestControls';
export { BacktestResults } from './BacktestResults';
export { MetricsCard } from './MetricsCard';
export { PositionsTable } from './PositionsTable';
export { TradeLog } from './TradeLog';

View file

@ -0,0 +1 @@
export { useBacktest } from './useBacktest';

View file

@ -0,0 +1,169 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { BacktestService } from '../services/backtestService';
import type { BacktestConfig, BacktestResult, BacktestStatus } from '../types';
export function useBacktest() {
const [backtestId, setBacktestId] = useState<string | null>(null);
const [config, setConfig] = useState<BacktestConfig | null>(null);
const [status, setStatus] = useState<BacktestStatus>('idle');
const [results, setResults] = useState<BacktestResult | null>(null);
const [currentTime, setCurrentTime] = useState<number | null>(null);
const [progress, setProgress] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const cleanupRef = useRef<(() => void) | null>(null);
const handleConfigSubmit = useCallback(async (newConfig: BacktestConfig) => {
try {
setIsLoading(true);
setError(null);
// Create backtest
const { id } = await BacktestService.createBacktest(newConfig);
setBacktestId(id);
setConfig(newConfig);
setStatus('configured');
setResults(null);
setCurrentTime(null);
setProgress(0);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create backtest');
} finally {
setIsLoading(false);
}
}, []);
const handleStart = useCallback(async () => {
if (!backtestId) return;
try {
setIsLoading(true);
setError(null);
await BacktestService.startBacktest(backtestId);
setStatus('running');
// Start polling for updates
cleanupRef.current = await BacktestService.pollBacktestUpdates(
backtestId,
(newStatus, newProgress, newTime) => {
setStatus(newStatus);
if (newProgress !== undefined) setProgress(newProgress);
if (newTime !== undefined) setCurrentTime(newTime);
// Fetch full results when completed
if (newStatus === 'completed') {
BacktestService.getBacktestResults(backtestId).then(setResults);
}
}
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start backtest');
} finally {
setIsLoading(false);
}
}, [backtestId]);
const handlePause = useCallback(async () => {
if (!backtestId) return;
try {
setIsLoading(true);
setError(null);
await BacktestService.pauseBacktest(backtestId);
setStatus('paused');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to pause backtest');
} finally {
setIsLoading(false);
}
}, [backtestId]);
const handleResume = useCallback(async () => {
if (!backtestId) return;
try {
setIsLoading(true);
setError(null);
await BacktestService.resumeBacktest(backtestId);
setStatus('running');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to resume backtest');
} finally {
setIsLoading(false);
}
}, [backtestId]);
const handleStop = useCallback(async () => {
if (!backtestId) return;
try {
setIsLoading(true);
setError(null);
await BacktestService.stopBacktest(backtestId);
setStatus('stopped');
// Stop polling
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to stop backtest');
} finally {
setIsLoading(false);
}
}, [backtestId]);
const handleStep = useCallback(async () => {
if (!backtestId) return;
try {
setIsLoading(true);
setError(null);
await BacktestService.stepBacktest(backtestId);
// Get updated status
const statusUpdate = await BacktestService.getBacktestStatus(backtestId);
setStatus(statusUpdate.status);
if (statusUpdate.progress !== undefined) setProgress(statusUpdate.progress);
if (statusUpdate.currentTime !== undefined) setCurrentTime(statusUpdate.currentTime);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to step backtest');
} finally {
setIsLoading(false);
}
}, [backtestId]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (cleanupRef.current) {
cleanupRef.current();
}
};
}, []);
return {
backtestId,
config,
status,
results,
currentTime,
progress,
error,
isLoading,
handleConfigSubmit,
handleStart,
handlePause,
handleResume,
handleStop,
handleStep,
};
}

View file

@ -0,0 +1,2 @@
export { BacktestPage } from './BacktestPage';
export * from './types';

View file

@ -0,0 +1,173 @@
import type { BacktestConfig, BacktestResult, BacktestStatus } from '../types';
const API_BASE = '/api/backtest';
export class BacktestService {
static async createBacktest(config: BacktestConfig): Promise<{ id: string }> {
const response = await fetch(`${API_BASE}/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...config,
startDate: config.startDate.toISOString(),
endDate: config.endDate.toISOString(),
}),
});
if (!response.ok) {
throw new Error('Failed to create backtest');
}
return response.json();
}
static async startBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}/start`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to start backtest');
}
}
static async pauseBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}/pause`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to pause backtest');
}
}
static async resumeBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}/resume`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to resume backtest');
}
}
static async stopBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}/stop`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to stop backtest');
}
}
static async stepBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}/step`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Failed to step backtest');
}
}
static async getBacktestStatus(id: string): Promise<{
status: BacktestStatus;
currentTime?: number;
progress?: number;
}> {
const response = await fetch(`${API_BASE}/${id}/status`);
if (!response.ok) {
throw new Error('Failed to get backtest status');
}
return response.json();
}
static async getBacktestResults(id: string): Promise<BacktestResult> {
const response = await fetch(`${API_BASE}/${id}/results`);
if (!response.ok) {
throw new Error('Failed to get backtest results');
}
const data = await response.json();
// Convert date strings back to Date objects
return {
...data,
config: {
...data.config,
startDate: new Date(data.config.startDate),
endDate: new Date(data.config.endDate),
},
};
}
static async listBacktests(): Promise<BacktestResult[]> {
const response = await fetch(`${API_BASE}/list`);
if (!response.ok) {
throw new Error('Failed to list backtests');
}
const data = await response.json();
// Convert date strings back to Date objects
return data.map((backtest: any) => ({
...backtest,
config: {
...backtest.config,
startDate: new Date(backtest.config.startDate),
endDate: new Date(backtest.config.endDate),
},
}));
}
static async deleteBacktest(id: string): Promise<void> {
const response = await fetch(`${API_BASE}/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete backtest');
}
}
// Helper method to poll for updates
static async pollBacktestUpdates(
id: string,
onUpdate: (status: BacktestStatus, progress?: number, currentTime?: number) => void,
interval: number = 1000
): Promise<() => void> {
let isPolling = true;
const poll = async () => {
while (isPolling) {
try {
const { status, progress, currentTime } = await this.getBacktestStatus(id);
onUpdate(status, progress, currentTime);
if (status === 'completed' || status === 'error' || status === 'stopped') {
isPolling = false;
break;
}
} catch (error) {
console.error('Polling error:', error);
}
await new Promise(resolve => setTimeout(resolve, interval));
}
};
poll();
// Return cleanup function
return () => {
isPolling = false;
};
}
}

View file

@ -0,0 +1,88 @@
export type BacktestStatus =
| 'idle'
| 'configured'
| 'running'
| 'paused'
| 'stopped'
| 'completed'
| 'error';
export interface BacktestConfig {
name: string;
startDate: Date;
endDate: Date;
initialCapital: number;
symbols: string[];
strategy: string;
speedMultiplier: number;
commission: number;
slippage: number;
}
export interface BacktestMetrics {
totalReturn: number;
sharpeRatio: number;
maxDrawdown: number;
winRate: number;
totalTrades: number;
profitableTrades: number;
}
export interface Position {
symbol: string;
quantity: number;
averagePrice: number;
currentPrice: number;
realizedPnl: number;
unrealizedPnl: number;
lastUpdate: string;
}
export interface Trade {
id: string;
timestamp: string;
symbol: string;
side: 'buy' | 'sell';
quantity: number;
price: number;
commission: number;
pnl?: number;
}
export interface PerformanceDataPoint {
timestamp: string;
portfolioValue: number;
pnl: number;
drawdown: number;
}
export interface BacktestResult {
id: string;
config: BacktestConfig;
metrics: BacktestMetrics;
positions: Position[];
trades: Trade[];
performanceData: PerformanceDataPoint[];
}
export interface OrderBookLevel {
price: number;
size: number;
orderCount?: number;
}
export interface OrderBookSnapshot {
symbol: string;
timestamp: string;
bids: OrderBookLevel[];
asks: OrderBookLevel[];
}
export interface RiskMetrics {
currentExposure: number;
dailyPnl: number;
positionCount: number;
grossExposure: number;
maxPositionSize: number;
utilizationPct: number;
}

View file

@ -0,0 +1,12 @@
export type {
BacktestStatus,
BacktestConfig,
BacktestMetrics,
Position,
Trade,
PerformanceDataPoint,
BacktestResult,
OrderBookLevel,
OrderBookSnapshot,
RiskMetrics,
} from './backtest.types';

View file

@ -0,0 +1,268 @@
import { useState, useEffect } from 'react';
import { SymbolChart } from './components/SymbolChart';
import { ChartToolbar } from './components/ChartToolbar';
import { IndicatorList } from './components/IndicatorList';
import { IndicatorSelector } from './components/IndicatorSelector';
import { IndicatorSettings, type IndicatorConfig } from './components/IndicatorSettings';
import type { ChartConfig, CandlestickData, MarketData } from './types';
// Mock data generator for demonstration
function generateMockData(days: number = 365): CandlestickData[] {
const data: CandlestickData[] = [];
const now = new Date();
let price = 100;
for (let i = days; i >= 0; i--) {
const date = new Date(now);
date.setDate(date.getDate() - i);
// Generate realistic price movement
const volatility = 0.02;
const trend = Math.random() > 0.5 ? 1 : -1;
const change = (Math.random() * volatility * 2 - volatility) * price;
const open = price;
const close = price + change * trend;
const high = Math.max(open, close) + Math.random() * volatility * price;
const low = Math.min(open, close) - Math.random() * volatility * price;
const volume = Math.floor(1000000 + Math.random() * 9000000);
data.push({
time: Math.floor(date.getTime() / 1000), // Convert to Unix timestamp (seconds)
open: parseFloat(open.toFixed(2)),
high: parseFloat(high.toFixed(2)),
low: parseFloat(low.toFixed(2)),
close: parseFloat(close.toFixed(2)),
volume,
});
price = close;
}
return data;
}
export function ChartPage() {
const [config, setConfig] = useState<ChartConfig>({
symbol: 'AAPL',
interval: '1d',
chartType: 'candlestick',
showVolume: true,
indicators: [],
theme: 'dark',
});
const [chartData, setChartData] = useState<CandlestickData[]>([]);
const [marketData, setMarketData] = useState<MarketData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showIndicatorSelector, setShowIndicatorSelector] = useState(false);
const [showIndicatorSettings, setShowIndicatorSettings] = useState(false);
const [selectedIndicator, setSelectedIndicator] = useState<IndicatorConfig | null>(null);
const [activeIndicators, setActiveIndicators] = useState<IndicatorConfig[]>([
{
id: 'VOLUME',
name: 'Volume',
type: 'overlay',
visible: true,
parameters: {},
style: {
color: '#3b82f6',
lineWidth: 1,
lineStyle: 'solid',
},
}
]);
const [showSidebar, setShowSidebar] = useState(false);
// Keyboard shortcuts
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === '/') {
e.preventDefault();
setShowIndicatorSelector(true);
}
};
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []);
// Load chart data
useEffect(() => {
setIsLoading(true);
// Simulate API call delay
setTimeout(() => {
const data = generateMockData();
setChartData(data);
// Set market data from latest candle
if (data.length > 0) {
const latest = data[data.length - 1];
const previous = data[data.length - 2];
const change = latest.close - previous.close;
const changePercent = (change / previous.close) * 100;
setMarketData({
symbol: config.symbol,
name: 'Apple Inc.',
price: latest.close,
change,
changePercent,
volume: latest.volume || 0,
high: latest.high,
low: latest.low,
open: latest.open,
previousClose: previous.close,
timestamp: new Date().toISOString(),
});
}
setIsLoading(false);
}, 1000);
}, [config.symbol, config.interval]);
const handleConfigChange = (updates: Partial<ChartConfig>) => {
setConfig(prev => ({ ...prev, ...updates }));
};
const handleAddIndicators = (indicatorIds: string[]) => {
// Filter out indicators that already exist
const newIndicatorIds = indicatorIds.filter(id => !activeIndicators.find(i => i.id === id));
const newIndicators = newIndicatorIds.map(id => {
const colors: Record<string, string> = {
SMA20: '#ff9800',
SMA50: '#2196f3',
EMA20: '#4caf50',
BB: '#ff5252',
VWAP: '#9c27b0',
RSI: '#00bcd4',
MACD: '#795548',
};
return {
id,
name: id.replace(/(\d+)/, ' $1'),
type: ['RSI', 'MACD'].includes(id) ? 'panel' : 'overlay',
visible: true,
parameters: {},
style: {
color: colors[id] || '#9e9e9e',
lineWidth: 2,
lineStyle: 'solid' as const,
},
} as IndicatorConfig;
});
const updatedIndicators = [...activeIndicators, ...newIndicators];
setActiveIndicators(updatedIndicators);
setConfig(prev => ({ ...prev, indicators: updatedIndicators.filter(i => i.id !== 'VOLUME').map(i => i.id) }));
};
const handleRemoveIndicator = (id: string) => {
// Don't allow removing Volume indicator
if (id === 'VOLUME') return;
setActiveIndicators(prev => prev.filter(i => i.id !== id));
setConfig(prev => ({ ...prev, indicators: prev.indicators.filter(i => i !== id) }));
};
const handleToggleIndicatorVisibility = (id: string) => {
setActiveIndicators(prev => prev.map(i =>
i.id === id ? { ...i, visible: !i.visible } : i
));
};
const handleIndicatorSettings = (id: string) => {
const indicator = activeIndicators.find(i => i.id === id);
if (indicator) {
setSelectedIndicator(indicator);
setShowIndicatorSettings(true);
}
};
const handleSaveIndicatorSettings = (updatedConfig: IndicatorConfig) => {
setActiveIndicators(prev => prev.map(i =>
i.id === updatedConfig.id ? updatedConfig : i
));
};
return (
<div className="flex flex-col h-full">
<div className="flex-shrink-0">
<h1 className="text-lg font-bold text-text-primary mb-2 px-8 pt-4">Market Charts</h1>
<p className="text-text-secondary mb-4 text-sm px-8">
Real-time market data and advanced charting powered by TradingView.
</p>
</div>
<div className="flex-1 flex bg-surface-secondary rounded-lg border border-border mx-8 mb-8 overflow-hidden">
{/* Left Sidebar - Indicator List */}
{showSidebar && (
<IndicatorList
indicators={activeIndicators.map(i => ({
id: i.id,
name: i.name,
type: i.type,
visible: i.visible,
color: i.style.color,
}))}
onRemove={handleRemoveIndicator}
onToggleVisibility={handleToggleIndicatorVisibility}
onSettings={handleIndicatorSettings}
onAddIndicator={() => setShowIndicatorSelector(true)}
/>
)}
{/* Main Chart Area */}
<div className="flex-1 flex flex-col">
<ChartToolbar config={config} onConfigChange={handleConfigChange} />
<div className="flex-1">
{isLoading ? (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-primary-500 mx-auto mb-4"></div>
<p className="text-sm text-text-secondary">Loading chart data...</p>
</div>
</div>
) : (
<SymbolChart
symbol={config.symbol}
data={chartData}
config={{
...config,
indicators: activeIndicators.filter(i => i.visible && i.id !== 'VOLUME').map(i => i.id),
showVolume: activeIndicators.find(i => i.id === 'VOLUME')?.visible ?? true,
}}
activeIndicators={activeIndicators}
onIndicatorSettings={handleIndicatorSettings}
onIndicatorRemove={handleRemoveIndicator}
onIndicatorToggle={handleToggleIndicatorVisibility}
onAddIndicator={() => setShowIndicatorSelector(true)}
className="h-full"
/>
)}
</div>
</div>
</div>
{/* Indicator Selector Modal */}
<IndicatorSelector
isOpen={showIndicatorSelector}
onClose={() => setShowIndicatorSelector(false)}
selectedIndicators={activeIndicators.map(i => i.id)}
onIndicatorsChange={handleAddIndicators}
/>
{/* Indicator Settings Modal */}
<IndicatorSettings
isOpen={showIndicatorSettings}
onClose={() => setShowIndicatorSettings(false)}
indicator={selectedIndicator}
onSave={handleSaveIndicatorSettings}
/>
</div>
);
}

View file

@ -0,0 +1,51 @@
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ChartErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Chart error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center h-full bg-surface-secondary rounded-lg border border-border p-8">
<div className="text-center">
<div className="text-error text-6xl mb-4"></div>
<h2 className="text-lg font-semibold text-text-primary mb-2">
Chart Loading Error
</h2>
<p className="text-sm text-text-secondary mb-4">
{this.state.error?.message || 'Unable to load the chart'}
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="px-4 py-2 bg-primary-500 text-white rounded-md text-sm font-medium hover:bg-primary-600 transition-colors"
>
Try Again
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View file

@ -0,0 +1,141 @@
import { Cog6ToothIcon, XMarkIcon, EyeIcon, EyeSlashIcon, ChevronUpIcon, ChevronDownIcon, PlusIcon } from '@heroicons/react/24/outline';
import { useState } from 'react';
interface IndicatorInfo {
id: string;
name: string;
value?: number;
color: string;
visible?: boolean;
}
interface ChartOverlayProps {
symbol: string;
price?: number;
change?: number;
changePercent?: number;
indicators: IndicatorInfo[];
onIndicatorSettings: (id: string) => void;
onIndicatorRemove: (id: string) => void;
onIndicatorToggle?: (id: string) => void;
onAddIndicator?: () => void;
}
export function ChartOverlay({
symbol,
price,
change,
changePercent,
indicators,
onIndicatorSettings,
onIndicatorRemove,
onIndicatorToggle,
onAddIndicator,
}: ChartOverlayProps) {
const [showIndicators, setShowIndicators] = useState(true);
return (
<div className="absolute top-2 left-2 z-10 pointer-events-none">
{/* Indicators */}
{(indicators.length > 0 || true) && (
<div>
{/* Indicators Header with Toggle */}
<div className="bg-surface-secondary/90 backdrop-blur-sm rounded-md px-2 py-0.5 shadow-sm pointer-events-auto flex items-center justify-between mb-0.5">
<span className="text-xs text-text-secondary">Indicators</span>
<button
onClick={() => setShowIndicators(!showIndicators)}
className="p-0.5 hover:bg-surface-tertiary rounded"
>
{showIndicators ? (
<ChevronUpIcon className="w-3 h-3 text-text-secondary" />
) : (
<ChevronDownIcon className="w-3 h-3 text-text-secondary" />
)}
</button>
</div>
{/* Indicator List */}
{showIndicators && (
<div className="space-y-0.5">
{indicators.map((indicator) => (
<div
key={indicator.id}
className={`bg-surface-secondary/90 backdrop-blur-sm rounded-md px-2 py-0.5 shadow-sm pointer-events-auto group flex items-center gap-1.5 ${
indicator.visible === false ? 'opacity-50' : ''
}`}
>
{/* Eye icon on the left */}
{onIndicatorToggle && (
<button
onClick={() => onIndicatorToggle(indicator.id)}
className="p-0.5 hover:bg-surface-tertiary rounded"
title={indicator.visible === false ? "Show indicator" : "Hide indicator"}
>
{indicator.visible === false ? (
<EyeIcon className="w-3 h-3 text-text-secondary" />
) : (
<EyeSlashIcon className="w-3 h-3 text-text-secondary" />
)}
</button>
)}
{/* Color dot */}
<div
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: indicator.color }}
/>
{/* Name and value */}
<div className="flex items-center gap-1.5">
<span className="text-xs text-text-secondary whitespace-nowrap">
{indicator.name}
</span>
{indicator.value !== undefined && indicator.visible !== false && (
<span className="text-xs font-medium text-text-primary">
{indicator.value.toFixed(2)}
</span>
)}
</div>
{/* Spacer to push buttons to the right */}
<div className="flex-1" />
{/* Settings and Remove on the right */}
<div className="flex items-center gap-0.5">
<button
onClick={() => onIndicatorSettings(indicator.id)}
className="p-0.5 hover:bg-surface-tertiary rounded"
>
<Cog6ToothIcon className="w-3 h-3 text-text-secondary" />
</button>
{indicator.id !== 'VOLUME' && (
<button
onClick={() => onIndicatorRemove(indicator.id)}
className="p-0.5 hover:bg-surface-tertiary rounded"
title="Remove indicator"
>
<XMarkIcon className="w-3 h-3 text-text-secondary" />
</button>
)}
</div>
</div>
))}
{/* Add Indicator Button */}
{onAddIndicator && (
<button
onClick={onAddIndicator}
className="bg-surface-secondary/90 backdrop-blur-sm rounded-md px-2 py-0.5 shadow-sm pointer-events-auto flex items-center gap-1.5 w-full hover:bg-surface-tertiary/90 transition-colors"
title="Add indicator"
>
<PlusIcon className="w-3 h-3 text-text-secondary" />
<span className="text-xs text-text-secondary">Add Indicator</span>
</button>
)}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,102 @@
import { ChartBarIcon } from '@heroicons/react/24/outline';
import type { ChartType, ChartInterval, ChartConfig } from '../types';
interface ChartToolbarProps {
config: ChartConfig;
onConfigChange: (config: Partial<ChartConfig>) => void;
}
const intervals: { value: ChartInterval; label: string }[] = [
{ value: '1m', label: '1m' },
{ value: '5m', label: '5m' },
{ value: '15m', label: '15m' },
{ value: '30m', label: '30m' },
{ value: '1h', label: '1H' },
{ value: '4h', label: '4H' },
{ value: '1d', label: '1D' },
{ value: '1w', label: '1W' },
{ value: '1M', label: '1M' },
];
const chartTypes: { value: ChartType; label: string; icon?: typeof ChartBarIcon }[] = [
{ value: 'candlestick', label: 'Candles' },
{ value: 'line', label: 'Line' },
{ value: 'area', label: 'Area' },
{ value: 'bar', label: 'Bars' },
];
export function ChartToolbar({ config, onConfigChange }: ChartToolbarProps) {
return (
<div className="flex items-center justify-between gap-4 p-4 bg-surface-secondary border-b border-border">
<div className="flex items-center gap-4">
{/* Symbol Input */}
<div className="flex items-center gap-2">
<input
type="text"
value={config.symbol}
onChange={(e) => onConfigChange({ symbol: e.target.value.toUpperCase() })}
placeholder="Symbol"
className="px-3 py-1.5 bg-background border border-border rounded-md text-sm font-medium text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500 w-24"
/>
</div>
{/* Interval Selector */}
<div className="flex items-center gap-1 bg-background rounded-md border border-border p-0.5">
{intervals.map(({ value, label }) => (
<button
key={value}
onClick={() => onConfigChange({ interval: value })}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
config.interval === value
? 'bg-primary-500 text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-surface-secondary'
}`}
>
{label}
</button>
))}
</div>
{/* Chart Type Selector */}
<div className="flex items-center gap-1 bg-background rounded-md border border-border p-0.5">
{chartTypes.map(({ value, label }) => (
<button
key={value}
onClick={() => onConfigChange({ chartType: value })}
className={`px-3 py-1 text-xs font-medium rounded transition-colors ${
config.chartType === value
? 'bg-primary-500 text-white'
: 'text-text-secondary hover:text-text-primary hover:bg-surface-secondary'
}`}
>
{label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
{/* Volume Toggle */}
<button
onClick={() => onConfigChange({ showVolume: !config.showVolume })}
className={`px-3 py-1.5 text-xs font-medium rounded-md border transition-colors ${
config.showVolume
? 'bg-primary-500 text-white border-primary-500'
: 'bg-background text-text-secondary border-border hover:text-text-primary hover:border-primary-500/50'
}`}
>
Volume
</button>
{/* Theme Toggle */}
<button
onClick={() => onConfigChange({ theme: config.theme === 'dark' ? 'light' : 'dark' })}
className="px-3 py-1.5 bg-background text-text-secondary text-xs font-medium rounded-md border border-border hover:text-text-primary hover:border-primary-500/50 transition-colors"
>
{config.theme === 'dark' ? '☀️' : '🌙'}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,193 @@
import { Cog6ToothIcon, XMarkIcon, EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { useState } from 'react';
interface IndicatorItem {
id: string;
name: string;
type: 'overlay' | 'panel';
visible: boolean;
color?: string;
}
interface IndicatorListProps {
indicators: IndicatorItem[];
onRemove: (id: string) => void;
onToggleVisibility: (id: string) => void;
onSettings: (id: string) => void;
onAddIndicator: () => void;
}
export function IndicatorList({
indicators,
onRemove,
onToggleVisibility,
onSettings,
onAddIndicator
}: IndicatorListProps) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
// Group indicators by type
const overlayIndicators = indicators.filter(i => i.type === 'overlay');
const panelIndicators = indicators.filter(i => i.type === 'panel');
return (
<div className="w-64 bg-surface-secondary border-r border-border flex flex-col h-full">
{/* Header */}
<div className="p-3 border-b border-border">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary">Indicators</h3>
<button
onClick={onAddIndicator}
className="text-xs text-primary-500 hover:text-primary-400 font-medium"
>
+ Add
</button>
</div>
</div>
{/* Indicator List */}
<div className="flex-1 overflow-y-auto">
{/* Main Chart Section */}
<div className="p-3">
<div className="text-xs text-text-secondary mb-2 uppercase tracking-wider">Main Chart</div>
{/* Price Action */}
<div className="bg-surface-tertiary rounded-md p-2 mb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-primary-500 rounded-full"></div>
<span className="text-sm text-text-primary">Price</span>
</div>
<button className="p-1 hover:bg-surface-secondary rounded">
<Cog6ToothIcon className="w-3.5 h-3.5 text-text-secondary" />
</button>
</div>
</div>
{/* Volume */}
<div className="bg-surface-tertiary rounded-md p-2 mb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="text-sm text-text-primary">Volume</span>
</div>
<div className="flex items-center gap-1">
<button className="p-1 hover:bg-surface-secondary rounded">
<EyeIcon className="w-3.5 h-3.5 text-text-secondary" />
</button>
<button className="p-1 hover:bg-surface-secondary rounded">
<Cog6ToothIcon className="w-3.5 h-3.5 text-text-secondary" />
</button>
</div>
</div>
</div>
{/* Overlay Indicators */}
{overlayIndicators.map(indicator => (
<div
key={indicator.id}
className="bg-surface-tertiary rounded-md p-2 mb-2 group"
onMouseEnter={() => setHoveredId(indicator.id)}
onMouseLeave={() => setHoveredId(null)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: indicator.color || '#9e9e9e' }}
></div>
<span className="text-sm text-text-primary">{indicator.name}</span>
</div>
<div className={`flex items-center gap-1 transition-opacity ${
hoveredId === indicator.id ? 'opacity-100' : 'opacity-0'
}`}>
<button
onClick={() => onToggleVisibility(indicator.id)}
className="p-1 hover:bg-surface-secondary rounded"
>
{indicator.visible ?
<EyeIcon className="w-3.5 h-3.5 text-text-secondary" /> :
<EyeSlashIcon className="w-3.5 h-3.5 text-text-secondary" />
}
</button>
<button
onClick={() => onSettings(indicator.id)}
className="p-1 hover:bg-surface-secondary rounded"
>
<Cog6ToothIcon className="w-3.5 h-3.5 text-text-secondary" />
</button>
<button
onClick={() => onRemove(indicator.id)}
className="p-1 hover:bg-surface-secondary rounded"
>
<XMarkIcon className="w-3.5 h-3.5 text-text-secondary" />
</button>
</div>
</div>
</div>
))}
</div>
{/* Panel Indicators */}
{panelIndicators.length > 0 && (
<div className="p-3 border-t border-border">
<div className="text-xs text-text-secondary mb-2 uppercase tracking-wider">Separate Panels</div>
{panelIndicators.map(indicator => (
<div
key={indicator.id}
className="bg-surface-tertiary rounded-md p-2 mb-2 group"
onMouseEnter={() => setHoveredId(indicator.id)}
onMouseLeave={() => setHoveredId(null)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: indicator.color || '#9e9e9e' }}
></div>
<span className="text-sm text-text-primary">{indicator.name}</span>
</div>
<div className={`flex items-center gap-1 transition-opacity ${
hoveredId === indicator.id ? 'opacity-100' : 'opacity-0'
}`}>
<button
onClick={() => onToggleVisibility(indicator.id)}
className="p-1 hover:bg-surface-secondary rounded"
>
{indicator.visible ?
<EyeIcon className="w-3.5 h-3.5 text-text-secondary" /> :
<EyeSlashIcon className="w-3.5 h-3.5 text-text-secondary" />
}
</button>
<button
onClick={() => onSettings(indicator.id)}
className="p-1 hover:bg-surface-secondary rounded"
>
<Cog6ToothIcon className="w-3.5 h-3.5 text-text-secondary" />
</button>
<button
onClick={() => onRemove(indicator.id)}
className="p-1 hover:bg-surface-secondary rounded"
>
<XMarkIcon className="w-3.5 h-3.5 text-text-secondary" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="p-3 border-t border-border">
<button
onClick={onAddIndicator}
className="w-full px-3 py-2 bg-primary-500/10 text-primary-500 text-sm font-medium rounded-md hover:bg-primary-500/20 transition-colors"
>
Add Indicator
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,164 @@
import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
interface IndicatorOption {
id: string;
name: string;
description: string;
category: 'overlay' | 'oscillator';
}
const availableIndicators: IndicatorOption[] = [
{ id: 'SMA20', name: 'SMA 20', description: 'Simple Moving Average (20 periods)', category: 'overlay' },
{ id: 'SMA50', name: 'SMA 50', description: 'Simple Moving Average (50 periods)', category: 'overlay' },
{ id: 'EMA20', name: 'EMA 20', description: 'Exponential Moving Average (20 periods)', category: 'overlay' },
{ id: 'BB', name: 'Bollinger Bands', description: 'Bollinger Bands (20, 2)', category: 'overlay' },
{ id: 'VWAP', name: 'VWAP', description: 'Volume Weighted Average Price', category: 'overlay' },
{ id: 'RSI', name: 'RSI', description: 'Relative Strength Index (14)', category: 'oscillator' },
{ id: 'MACD', name: 'MACD', description: 'Moving Average Convergence Divergence', category: 'oscillator' },
];
interface IndicatorSelectorProps {
isOpen: boolean;
onClose: () => void;
selectedIndicators: string[];
onIndicatorsChange: (indicators: string[]) => void;
}
export function IndicatorSelector({ isOpen, onClose, selectedIndicators, onIndicatorsChange }: IndicatorSelectorProps) {
const [selected, setSelected] = useState<Set<string>>(new Set(selectedIndicators.filter(id => id !== 'VOLUME')));
const handleToggle = (indicatorId: string) => {
const newSelected = new Set(selected);
if (newSelected.has(indicatorId)) {
newSelected.delete(indicatorId);
} else {
newSelected.add(indicatorId);
}
setSelected(newSelected);
};
const handleApply = () => {
onIndicatorsChange(Array.from(selected));
onClose();
};
const overlayIndicators = availableIndicators.filter(i => i.category === 'overlay');
const oscillatorIndicators = availableIndicators.filter(i => i.category === 'oscillator');
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-surface-secondary text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="bg-surface-secondary px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<Dialog.Title as="h3" className="text-lg font-semibold text-text-primary">
Technical Indicators
</Dialog.Title>
<button
onClick={onClose}
className="rounded-md text-text-secondary hover:text-text-primary"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="space-y-6">
{/* Overlay Indicators */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-3">Overlay Indicators</h4>
<div className="space-y-2">
{overlayIndicators.map(indicator => (
<label
key={indicator.id}
className="flex items-start space-x-3 p-3 rounded-md hover:bg-surface-tertiary cursor-pointer"
>
<input
type="checkbox"
checked={selected.has(indicator.id)}
onChange={() => handleToggle(indicator.id)}
className="mt-0.5 h-4 w-4 rounded border-border text-primary-500 focus:ring-primary-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-text-primary">{indicator.name}</div>
<div className="text-xs text-text-secondary">{indicator.description}</div>
</div>
</label>
))}
</div>
</div>
{/* Oscillator Indicators */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-3">Oscillators (Coming Soon)</h4>
<div className="space-y-2 opacity-50">
{oscillatorIndicators.map(indicator => (
<label
key={indicator.id}
className="flex items-start space-x-3 p-3 rounded-md cursor-not-allowed"
>
<input
type="checkbox"
disabled
checked={false}
className="mt-0.5 h-4 w-4 rounded border-border text-primary-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-text-primary">{indicator.name}</div>
<div className="text-xs text-text-secondary">{indicator.description}</div>
</div>
</label>
))}
</div>
</div>
</div>
</div>
<div className="bg-surface-tertiary px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<button
type="button"
className="inline-flex w-full justify-center rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-600 sm:ml-3 sm:w-auto"
onClick={handleApply}
>
Apply
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-surface-secondary px-4 py-2 text-sm font-medium text-text-primary shadow-sm ring-1 ring-inset ring-border hover:bg-surface-tertiary sm:mt-0 sm:w-auto"
onClick={onClose}
>
Cancel
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View file

@ -0,0 +1,261 @@
import { Fragment, useState, useEffect } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
export interface IndicatorConfig {
id: string;
name: string;
type: 'overlay' | 'panel';
parameters: Record<string, any>;
style: {
color?: string;
lineWidth?: number;
lineStyle?: 'solid' | 'dashed' | 'dotted';
};
}
interface IndicatorSettingsProps {
isOpen: boolean;
onClose: () => void;
indicator: IndicatorConfig | null;
onSave: (config: IndicatorConfig) => void;
}
const indicatorParameters: Record<string, Array<{ key: string; label: string; type: 'number' | 'select'; min?: number; max?: number; options?: string[] }>> = {
SMA20: [
{ key: 'period', label: 'Period', type: 'number', min: 1, max: 200 },
],
SMA50: [
{ key: 'period', label: 'Period', type: 'number', min: 1, max: 200 },
],
EMA20: [
{ key: 'period', label: 'Period', type: 'number', min: 1, max: 200 },
],
BB: [
{ key: 'period', label: 'Period', type: 'number', min: 1, max: 200 },
{ key: 'stdDev', label: 'Standard Deviation', type: 'number', min: 0.1, max: 5 },
],
RSI: [
{ key: 'period', label: 'Period', type: 'number', min: 1, max: 100 },
{ key: 'overbought', label: 'Overbought Level', type: 'number', min: 50, max: 100 },
{ key: 'oversold', label: 'Oversold Level', type: 'number', min: 0, max: 50 },
],
MACD: [
{ key: 'fastPeriod', label: 'Fast Period', type: 'number', min: 1, max: 200 },
{ key: 'slowPeriod', label: 'Slow Period', type: 'number', min: 1, max: 200 },
{ key: 'signalPeriod', label: 'Signal Period', type: 'number', min: 1, max: 200 },
],
};
const defaultParameters: Record<string, Record<string, any>> = {
SMA20: { period: 20 },
SMA50: { period: 50 },
EMA20: { period: 20 },
BB: { period: 20, stdDev: 2 },
RSI: { period: 14, overbought: 70, oversold: 30 },
MACD: { fastPeriod: 12, slowPeriod: 26, signalPeriod: 9 },
VWAP: {},
};
const lineStyles = [
{ value: 'solid', label: 'Solid' },
{ value: 'dashed', label: 'Dashed' },
{ value: 'dotted', label: 'Dotted' },
];
export function IndicatorSettings({ isOpen, onClose, indicator, onSave }: IndicatorSettingsProps) {
const [config, setConfig] = useState<IndicatorConfig | null>(null);
useEffect(() => {
if (indicator) {
setConfig({
...indicator,
parameters: { ...defaultParameters[indicator.id], ...indicator.parameters },
});
}
}, [indicator]);
if (!config || !indicator) return null;
const handleParameterChange = (key: string, value: any) => {
setConfig({
...config,
parameters: {
...config.parameters,
[key]: value,
},
});
};
const handleStyleChange = (key: string, value: any) => {
setConfig({
...config,
style: {
...config.style,
[key]: value,
},
});
};
const handleSave = () => {
onSave(config);
onClose();
};
const parameters = indicatorParameters[indicator.id] || [];
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/50 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-surface-secondary text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-md">
<div className="bg-surface-secondary px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<Dialog.Title as="h3" className="text-lg font-semibold text-text-primary">
{config.name} Settings
</Dialog.Title>
<button
onClick={onClose}
className="rounded-md text-text-secondary hover:text-text-primary"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="space-y-4">
{/* Parameters */}
{parameters.length > 0 && (
<div>
<h4 className="text-sm font-medium text-text-primary mb-3">Parameters</h4>
<div className="space-y-3">
{parameters.map(param => (
<div key={param.key}>
<label className="block text-sm text-text-secondary mb-1">
{param.label}
</label>
{param.type === 'number' ? (
<input
type="number"
min={param.min}
max={param.max}
value={config.parameters[param.key] || 0}
onChange={(e) => handleParameterChange(param.key, parseFloat(e.target.value))}
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
) : (
<select
value={config.parameters[param.key] || ''}
onChange={(e) => handleParameterChange(param.key, e.target.value)}
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
>
{param.options?.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
)}
</div>
))}
</div>
</div>
)}
{/* Style */}
<div>
<h4 className="text-sm font-medium text-text-primary mb-3">Style</h4>
<div className="space-y-3">
<div>
<label className="block text-sm text-text-secondary mb-1">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={config.style.color || '#3b82f6'}
onChange={(e) => handleStyleChange('color', e.target.value)}
className="w-10 h-10 rounded border border-border cursor-pointer"
/>
<input
type="text"
value={config.style.color || '#3b82f6'}
onChange={(e) => handleStyleChange('color', e.target.value)}
className="flex-1 px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
</div>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">Line Width</label>
<input
type="range"
min="1"
max="5"
value={config.style.lineWidth || 2}
onChange={(e) => handleStyleChange('lineWidth', parseInt(e.target.value))}
className="w-full"
/>
<div className="text-center text-sm text-text-secondary mt-1">
{config.style.lineWidth || 2}px
</div>
</div>
<div>
<label className="block text-sm text-text-secondary mb-1">Line Style</label>
<select
value={config.style.lineStyle || 'solid'}
onChange={(e) => handleStyleChange('lineStyle', e.target.value)}
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500"
>
{lineStyles.map(style => (
<option key={style.value} value={style.value}>{style.label}</option>
))}
</select>
</div>
</div>
</div>
</div>
</div>
<div className="bg-surface-tertiary px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
<button
type="button"
className="inline-flex w-full justify-center rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-600 sm:ml-3 sm:w-auto"
onClick={handleSave}
>
Save
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-surface-secondary px-4 py-2 text-sm font-medium text-text-primary shadow-sm ring-1 ring-inset ring-border hover:bg-surface-tertiary sm:mt-0 sm:w-auto"
onClick={onClose}
>
Cancel
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View file

@ -0,0 +1,84 @@
import { ArrowUpIcon, ArrowDownIcon } from '@heroicons/react/24/solid';
import type { MarketData } from '../types';
interface MarketOverviewProps {
data: MarketData | null;
loading?: boolean;
}
export function MarketOverview({ data, loading }: MarketOverviewProps) {
if (loading || !data) {
return (
<div className="p-4 bg-surface-secondary border-b border-border">
<div className="animate-pulse">
<div className="h-6 w-32 bg-surface-tertiary rounded mb-2"></div>
<div className="h-8 w-24 bg-surface-tertiary rounded"></div>
</div>
</div>
);
}
const isPositive = data.change >= 0;
return (
<div className="p-4 bg-surface-secondary border-b border-border">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3 mb-1">
<h2 className="text-xl font-bold text-text-primary">{data.symbol}</h2>
<span className="text-sm text-text-secondary">{data.name}</span>
</div>
<div className="flex items-baseline gap-3">
<span className="text-3xl font-bold text-text-primary">
${data.price.toFixed(2)}
</span>
<div className={`flex items-center gap-1 ${isPositive ? 'text-success' : 'text-error'}`}>
{isPositive ? (
<ArrowUpIcon className="w-4 h-4" />
) : (
<ArrowDownIcon className="w-4 h-4" />
)}
<span className="text-lg font-medium">
${Math.abs(data.change).toFixed(2)}
</span>
<span className="text-lg font-medium">
({isPositive ? '+' : ''}{data.changePercent.toFixed(2)}%)
</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div>
<span className="text-text-muted">Open</span>
<p className="text-text-primary font-medium">${data.open.toFixed(2)}</p>
</div>
<div>
<span className="text-text-muted">High</span>
<p className="text-text-primary font-medium">${data.high.toFixed(2)}</p>
</div>
<div>
<span className="text-text-muted">Low</span>
<p className="text-text-primary font-medium">${data.low.toFixed(2)}</p>
</div>
<div>
<span className="text-text-muted">Prev Close</span>
<p className="text-text-primary font-medium">${data.previousClose.toFixed(2)}</p>
</div>
<div>
<span className="text-text-muted">Volume</span>
<p className="text-text-primary font-medium">
{(data.volume / 1000000).toFixed(2)}M
</p>
</div>
<div>
<span className="text-text-muted">Time</span>
<p className="text-text-primary font-medium">
{new Date(data.timestamp).toLocaleTimeString()}
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,424 @@
import * as LightweightCharts from 'lightweight-charts';
import { useEffect, useRef, useState } from 'react';
import type { CandlestickData, ChartConfig, ChartOptions } from '../types';
import { calculateBollingerBands, calculateEMA, calculateSMA, calculateVWAP } from '../utils/indicators';
import { ChartOverlay } from './ChartOverlay';
interface SymbolChartProps {
symbol: string;
data: CandlestickData[];
config?: Partial<ChartConfig>;
options?: ChartOptions;
className?: string;
onIndicatorSettings?: (id: string) => void;
onIndicatorRemove?: (id: string) => void;
onIndicatorToggle?: (id: string) => void;
onAddIndicator?: () => void;
activeIndicators?: Array<{
id: string;
name: string;
color: string;
visible: boolean;
}>;
}
export function SymbolChart({
symbol,
data,
config = {},
options = {},
className = '',
onIndicatorSettings,
onIndicatorRemove,
onIndicatorToggle,
onAddIndicator,
activeIndicators = [],
}: SymbolChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<LightweightCharts.IChartApi | null>(null);
const seriesRef = useRef<LightweightCharts.ISeriesApi<any> | null>(null);
const volumeSeriesRef = useRef<LightweightCharts.ISeriesApi<any> | null>(null);
const indicatorSeriesRef = useRef<Map<string, LightweightCharts.ISeriesApi<any>>>(new Map());
const [chartInitialized, setChartInitialized] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [currentPrice, setCurrentPrice] = useState<{ price: number; change: number; changePercent: number } | null>(null);
const [indicatorValues, setIndicatorValues] = useState<Map<string, number>>(new Map());
// Default configuration
const chartConfig: ChartConfig = {
symbol,
interval: '1d',
chartType: 'candlestick',
showVolume: true,
indicators: [],
theme: 'dark',
...config,
};
// Set initial price from last candle
useEffect(() => {
if (data.length > 0 && !currentPrice) {
const latest = data[data.length - 1];
const previous = data.length > 1 ? data[data.length - 2] : latest;
const change = latest.close - previous.close;
const changePercent = (change / previous.close) * 100;
setCurrentPrice({ price: latest.close, change, changePercent });
}
}, [data, currentPrice]);
useEffect(() => {
if (!chartContainerRef.current) return;
// Create chart
const chart = LightweightCharts.createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: chartContainerRef.current.clientHeight || 500,
layout: {
background: { type: LightweightCharts.ColorType.Solid, color: chartConfig.theme === 'dark' ? '#0f0f0f' : '#ffffff' },
textColor: chartConfig.theme === 'dark' ? '#d1d5db' : '#374151',
},
grid: {
vertLines: {
color: chartConfig.theme === 'dark' ? '#1f2937' : '#e5e7eb',
visible: true,
},
horzLines: {
color: chartConfig.theme === 'dark' ? '#1f2937' : '#e5e7eb',
visible: true,
},
},
crosshair: {
mode: LightweightCharts.CrosshairMode.Normal,
},
rightPriceScale: {
borderColor: chartConfig.theme === 'dark' ? '#1f2937' : '#e5e7eb',
},
timeScale: {
borderColor: chartConfig.theme === 'dark' ? '#1f2937' : '#e5e7eb',
timeVisible: true,
secondsVisible: false,
},
});
chartRef.current = chart;
// Clear previous indicator series references
indicatorSeriesRef.current.clear();
// Create main series based on chart type
if (chartConfig.chartType === 'candlestick') {
seriesRef.current = chart.addCandlestickSeries({
upColor: '#10b981',
downColor: '#ef4444',
borderUpColor: '#10b981',
borderDownColor: '#ef4444',
wickUpColor: '#10b981',
wickDownColor: '#ef4444',
});
} else if (chartConfig.chartType === 'line') {
seriesRef.current = chart.addLineSeries({
color: '#3b82f6',
lineWidth: 2,
});
} else if (chartConfig.chartType === 'area') {
seriesRef.current = chart.addAreaSeries({
lineColor: '#3b82f6',
topColor: '#3b82f6',
bottomColor: 'rgba(59, 130, 246, 0.1)',
lineWidth: 2,
});
} else if (chartConfig.chartType === 'bar') {
seriesRef.current = chart.addBarSeries({
upColor: '#10b981',
downColor: '#ef4444',
});
}
// Create volume series if enabled
if (chartConfig.showVolume && data.some(d => d.volume)) {
volumeSeriesRef.current = chart.addHistogramSeries({
color: '#3b82f680',
priceFormat: {
type: 'volume',
},
priceScaleId: 'volume',
});
volumeSeriesRef.current.priceScale().applyOptions({
scaleMargins: {
top: 0.9, // highest point of the series will be 70% away from the top
bottom: 0,
},
});
}
// Set data
if (seriesRef.current && data.length > 0) {
if (chartConfig.chartType === 'candlestick' || chartConfig.chartType === 'bar') {
seriesRef.current.setData(data);
} else if (chartConfig.chartType === 'line' || chartConfig.chartType === 'area') {
// Convert candlestick data to line/area data (using close price)
const lineData = data.map(d => ({ time: d.time, value: d.close }));
seriesRef.current.setData(lineData);
}
// Set volume data if available
if (volumeSeriesRef.current) {
const volumeData = data
.filter(d => d.volume)
.map(d => ({
time: d.time,
value: d.volume!,
color: d.close >= d.open ? '#10b98180' : '#ef444480',
}));
volumeSeriesRef.current.setData(volumeData);
}
// Add indicators - include all indicators from activeIndicators, not just visible ones
if (activeIndicators && activeIndicators.length > 0) {
activeIndicators.filter(ind => ind.id !== 'VOLUME').forEach(indicator => {
const indicatorId = indicator.id;
if (indicatorId === 'SMA20') {
const sma20 = calculateSMA(data, 20);
const sma20Series = chart.addLineSeries({
color: indicator.style?.color || '#ff9800',
lineWidth: indicator.style?.lineWidth || 2,
title: 'SMA 20',
visible: indicator.visible,
});
sma20Series.setData(sma20);
indicatorSeriesRef.current.set('SMA20', sma20Series);
}
if (indicatorId === 'SMA50') {
const sma50 = calculateSMA(data, 50);
const sma50Series = chart.addLineSeries({
color: indicator.style?.color || '#2196f3',
lineWidth: indicator.style?.lineWidth || 2,
title: 'SMA 50',
visible: indicator.visible,
});
sma50Series.setData(sma50);
indicatorSeriesRef.current.set('SMA50', sma50Series);
}
if (indicatorId === 'EMA20') {
const ema20 = calculateEMA(data, 20);
const ema20Series = chart.addLineSeries({
color: indicator.style?.color || '#4caf50',
lineWidth: indicator.style?.lineWidth || 2,
title: 'EMA 20',
visible: indicator.visible,
});
ema20Series.setData(ema20);
indicatorSeriesRef.current.set('EMA20', ema20Series);
}
if (indicatorId === 'BB') {
const bb = calculateBollingerBands(data, 20, 2);
const bbUpperSeries = chart.addLineSeries({
color: indicator.style?.color || '#ff5252',
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dashed,
title: 'BB Upper',
visible: indicator.visible,
});
bbUpperSeries.setData(bb.upper);
indicatorSeriesRef.current.set('BB_upper', bbUpperSeries);
const bbMiddleSeries = chart.addLineSeries({
color: '#9e9e9e',
lineWidth: 1,
title: 'BB Middle',
visible: indicator.visible,
});
bbMiddleSeries.setData(bb.middle);
indicatorSeriesRef.current.set('BB_middle', bbMiddleSeries);
const bbLowerSeries = chart.addLineSeries({
color: '#4caf50',
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dashed,
title: 'BB Lower',
visible: indicator.visible,
});
bbLowerSeries.setData(bb.lower);
indicatorSeriesRef.current.set('BB_lower', bbLowerSeries);
}
if (indicatorId === 'VWAP') {
const vwap = calculateVWAP(data);
const vwapSeries = chart.addLineSeries({
color: indicator.style?.color || '#9c27b0',
lineWidth: indicator.style?.lineWidth || 2,
title: 'VWAP',
visible: indicator.visible,
});
vwapSeries.setData(vwap);
indicatorSeriesRef.current.set('VWAP', vwapSeries);
}
});
}
// Subscribe to crosshair move for tracking values
chart.subscribeCrosshairMove((param) => {
if (!param || !param.time || !param.seriesData) return;
// Update price info
const mainSeriesData = param.seriesData.get(seriesRef.current!);
if (mainSeriesData) {
const currentData = mainSeriesData as any;
const price = currentData.close || currentData.value || 0;
// Find previous close
const currentIndex = data.findIndex(d => d.time === param.time);
const previousClose = currentIndex > 0 ? data[currentIndex - 1].close : price;
const change = price - previousClose;
const changePercent = (change / previousClose) * 100;
setCurrentPrice({ price, change, changePercent });
}
// Update indicator values
const newIndicatorValues = new Map<string, number>();
indicatorSeriesRef.current.forEach((series, indicatorId) => {
const indicatorData = param.seriesData.get(series);
if (indicatorData && 'value' in indicatorData) {
newIndicatorValues.set(indicatorId, indicatorData.value);
}
});
setIndicatorValues(newIndicatorValues);
});
// Fit content only on initial load
chart.timeScale().fitContent();
setIsLoading(false);
setChartInitialized(true);
}
// Handle resize
const handleResize = () => {
if (chartContainerRef.current && chart) {
chart.applyOptions({
width: chartContainerRef.current.clientWidth,
height: chartContainerRef.current.clientHeight,
});
}
};
window.addEventListener('resize', handleResize);
// Cleanup
return () => {
window.removeEventListener('resize', handleResize);
if (chart) {
chart.remove();
}
};
}, [data.length, chartConfig.theme, chartConfig.chartType, activeIndicators.length]); // Include activeIndicators.length to recreate when indicators are added/removed
// Update data when it changes
useEffect(() => {
if (!seriesRef.current || !data.length) return;
if (chartConfig.chartType === 'candlestick' || chartConfig.chartType === 'bar') {
seriesRef.current.setData(data);
} else if (chartConfig.chartType === 'line' || chartConfig.chartType === 'area') {
const lineData = data.map(d => ({ time: d.time, value: d.close }));
seriesRef.current.setData(lineData);
}
if (volumeSeriesRef.current) {
const volumeData = data
.filter(d => d.volume)
.map(d => ({
time: d.time,
value: d.volume!,
color: d.close >= d.open ? '#10b98180' : '#ef444480',
}));
volumeSeriesRef.current.setData(volumeData);
}
// Don't fit content on data updates to preserve user's zoom/pan
// chartRef.current?.timeScale().fitContent();
}, [data, chartConfig.chartType]);
// Create a dependency string that captures visibility changes
const visibilityKey = activeIndicators.map(i => `${i.id}:${i.visible}`).join(',');
// Handle indicator visibility changes without recreating chart
useEffect(() => {
if (!chartRef.current) return;
// Small delay to ensure chart is fully initialized
const timeoutId = setTimeout(() => {
// Update volume visibility
if (volumeSeriesRef.current) {
const volumeVisible = chartConfig.showVolume;
volumeSeriesRef.current.applyOptions({
visible: volumeVisible,
});
}
// Update indicator visibility
activeIndicators.forEach(indicator => {
if (indicator.id === 'VOLUME') return; // Skip volume, handled above
// For indicators with multiple series (like Bollinger Bands)
if (indicator.id === 'BB') {
['BB_upper', 'BB_middle', 'BB_lower'].forEach(seriesId => {
const series = indicatorSeriesRef.current.get(seriesId);
if (series) {
series.applyOptions({ visible: indicator.visible });
}
});
} else {
// For single series indicators
const series = indicatorSeriesRef.current.get(indicator.id);
if (series) {
series.applyOptions({ visible: indicator.visible });
}
}
});
}, 100);
return () => clearTimeout(timeoutId);
}, [chartConfig.showVolume, visibilityKey]);
// Prepare indicator info for overlay - include all indicators
const indicatorInfo = activeIndicators.map(indicator => ({
id: indicator.id,
name: indicator.name,
value: indicator.visible ? indicatorValues.get(indicator.id) : undefined,
color: indicator.color,
visible: indicator.visible,
}));
return (
<div className={`relative ${className}`}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-primary-500"></div>
</div>
)}
{/* Chart container */}
<div ref={chartContainerRef} style={{ width: '100%', height: '100%' }} />
{/* Chart Overlay with indicators */}
{!isLoading && onIndicatorSettings && onIndicatorRemove && (
<ChartOverlay
symbol={symbol}
price={currentPrice?.price}
change={currentPrice?.change}
changePercent={currentPrice?.changePercent}
indicators={indicatorInfo}
onIndicatorSettings={onIndicatorSettings}
onIndicatorRemove={onIndicatorRemove}
onIndicatorToggle={onIndicatorToggle}
onAddIndicator={onAddIndicator}
/>
)}
</div>
);
}

View file

@ -0,0 +1,8 @@
export { SymbolChart } from './SymbolChart';
export { ChartToolbar } from './ChartToolbar';
export { MarketOverview } from './MarketOverview';
export { ChartErrorBoundary } from './ChartErrorBoundary';
export { IndicatorSelector } from './IndicatorSelector';
export { IndicatorList } from './IndicatorList';
export { IndicatorSettings } from './IndicatorSettings';
export { ChartOverlay } from './ChartOverlay';

View file

@ -0,0 +1 @@
export { useChartData } from './useChartData';

View file

@ -0,0 +1,145 @@
import { useState, useEffect, useRef } from 'react';
import { ChartDataService } from '../services/chartDataService';
import type { CandlestickData, MarketData, ChartInterval } from '../types';
interface UseChartDataOptions {
symbol: string;
interval: ChartInterval;
enableRealtime?: boolean;
}
export function useChartData({ symbol, interval, enableRealtime = true }: UseChartDataOptions) {
const [data, setData] = useState<CandlestickData[]>([]);
const [marketData, setMarketData] = useState<MarketData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const unsubscribeRef = useRef<(() => void) | null>(null);
// Fetch historical data
useEffect(() => {
let cancelled = false;
const fetchData = async () => {
try {
setIsLoading(true);
setError(null);
const { start, end } = ChartDataService.getDefaultDateRange(interval);
const historicalData = await ChartDataService.getHistoricalData(
symbol,
interval,
start,
end
);
if (!cancelled) {
setData(historicalData);
}
// Fetch current market data
const currentData = await ChartDataService.getRealtimeData(symbol);
if (!cancelled) {
setMarketData(currentData);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load chart data');
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
};
fetchData();
return () => {
cancelled = true;
};
}, [symbol, interval]);
// Subscribe to real-time updates
useEffect(() => {
if (!enableRealtime || isLoading || error) return;
const handleRealtimeUpdate = (newCandle: CandlestickData) => {
setData(prevData => {
const lastCandle = prevData[prevData.length - 1];
// Check if this is an update to the current candle or a new one
if (lastCandle && lastCandle.time === newCandle.time) {
// Update existing candle
return [...prevData.slice(0, -1), newCandle];
} else {
// Add new candle
return [...prevData, newCandle];
}
});
// Update market data
setMarketData(prev => {
if (!prev) return prev;
const change = newCandle.close - (prev.previousClose || prev.price);
const changePercent = (change / (prev.previousClose || prev.price)) * 100;
return {
...prev,
price: newCandle.close,
change,
changePercent,
high: Math.max(prev.high, newCandle.high),
low: Math.min(prev.low, newCandle.low),
volume: prev.volume + (newCandle.volume || 0),
timestamp: new Date().toISOString(),
};
});
};
const handleError = (error: Error) => {
console.error('Real-time data error:', error);
};
unsubscribeRef.current = ChartDataService.subscribeToRealtime(
symbol,
handleRealtimeUpdate,
handleError
);
return () => {
if (unsubscribeRef.current) {
unsubscribeRef.current();
unsubscribeRef.current = null;
}
};
}, [symbol, enableRealtime, isLoading, error]);
const refresh = async () => {
const { start, end } = ChartDataService.getDefaultDateRange(interval);
try {
setIsLoading(true);
const newData = await ChartDataService.getHistoricalData(symbol, interval, start, end);
setData(newData);
const currentData = await ChartDataService.getRealtimeData(symbol);
setMarketData(currentData);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to refresh data');
} finally {
setIsLoading(false);
}
};
return {
data,
marketData,
isLoading,
error,
refresh,
};
}

View file

@ -0,0 +1,3 @@
export { ChartPage } from './ChartPage';
export * from './types';
export * from './components';

View file

@ -0,0 +1,209 @@
import type { CandlestickData, MarketData, ChartInterval } from '../types';
const API_BASE = '/api/market-data';
export class ChartDataService {
static async getHistoricalData(
symbol: string,
interval: ChartInterval,
startDate?: Date,
endDate?: Date
): Promise<CandlestickData[]> {
const params = new URLSearchParams({
symbol,
interval,
...(startDate && { start: startDate.toISOString() }),
...(endDate && { end: endDate.toISOString() }),
});
const response = await fetch(`${API_BASE}/historical?${params}`);
if (!response.ok) {
throw new Error('Failed to fetch historical data');
}
const data = await response.json();
// Transform data to match lightweight-charts format
const candlestickData = data.map((candle: any) => {
// Convert time to Unix timestamp in seconds
let time: number;
const rawTime = candle.timestamp || candle.time;
if (typeof rawTime === 'string') {
// If it's a date string, parse it
time = Math.floor(new Date(rawTime).getTime() / 1000);
} else if (typeof rawTime === 'number') {
// If it's already a number, check if it's in milliseconds or seconds
time = rawTime > 9999999999 ? Math.floor(rawTime / 1000) : rawTime;
} else {
// Default to current time if invalid
time = Math.floor(Date.now() / 1000);
}
return {
time,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
volume: candle.volume,
};
});
// Sort by time and remove duplicates
const sortedData = candlestickData.sort((a, b) => a.time - b.time);
// Remove duplicates by keeping only the last entry for each timestamp
const uniqueData = sortedData.reduce((acc: CandlestickData[], current) => {
const lastItem = acc[acc.length - 1];
if (!lastItem || current.time !== lastItem.time) {
acc.push(current);
} else {
// Replace with current if duplicate timestamp
acc[acc.length - 1] = current;
}
return acc;
}, []);
return uniqueData;
}
static async getRealtimeData(symbol: string): Promise<MarketData> {
const response = await fetch(`${API_BASE}/quote/${symbol}`);
if (!response.ok) {
throw new Error('Failed to fetch market data');
}
return response.json();
}
static async searchSymbols(query: string): Promise<Array<{ symbol: string; name: string; exchange: string }>> {
const response = await fetch(`${API_BASE}/search?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Failed to search symbols');
}
return response.json();
}
// WebSocket connection for real-time updates
static subscribeToRealtime(
symbol: string,
onUpdate: (data: CandlestickData) => void,
onError?: (error: Error) => void
): () => void {
const ws = new WebSocket(`ws://localhost:3000/ws/market-data`);
let isConnected = false;
ws.onopen = () => {
isConnected = true;
// Subscribe to symbol
ws.send(JSON.stringify({
type: 'subscribe',
symbol,
}));
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.symbol === symbol) {
// Convert timestamp to Unix seconds
let time: number;
if (typeof data.timestamp === 'string') {
time = Math.floor(new Date(data.timestamp).getTime() / 1000);
} else if (typeof data.timestamp === 'number') {
time = data.timestamp > 9999999999 ? Math.floor(data.timestamp / 1000) : data.timestamp;
} else {
time = Math.floor(Date.now() / 1000);
}
onUpdate({
time,
open: data.open,
high: data.high,
low: data.low,
close: data.close,
volume: data.volume,
});
}
} catch (error) {
onError?.(error as Error);
}
};
ws.onerror = (error) => {
onError?.(new Error('WebSocket error'));
};
ws.onclose = () => {
isConnected = false;
};
// Return cleanup function
return () => {
if (isConnected) {
ws.send(JSON.stringify({
type: 'unsubscribe',
symbol,
}));
}
ws.close();
};
}
// Convert interval to milliseconds for calculations
static intervalToMs(interval: ChartInterval): number {
const map: Record<ChartInterval, number> = {
'1m': 60 * 1000,
'5m': 5 * 60 * 1000,
'15m': 15 * 60 * 1000,
'30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000,
'4h': 4 * 60 * 60 * 1000,
'1d': 24 * 60 * 60 * 1000,
'1w': 7 * 24 * 60 * 60 * 1000,
'1M': 30 * 24 * 60 * 60 * 1000,
};
return map[interval] || map['1d'];
}
// Get appropriate date range for interval
static getDefaultDateRange(interval: ChartInterval): { start: Date; end: Date } {
const end = new Date();
const start = new Date();
switch (interval) {
case '1m':
case '5m':
start.setHours(start.getHours() - 24); // 24 hours
break;
case '15m':
case '30m':
start.setDate(start.getDate() - 7); // 1 week
break;
case '1h':
start.setDate(start.getDate() - 30); // 1 month
break;
case '4h':
start.setMonth(start.getMonth() - 3); // 3 months
break;
case '1d':
start.setFullYear(start.getFullYear() - 1); // 1 year
break;
case '1w':
start.setFullYear(start.getFullYear() - 3); // 3 years
break;
case '1M':
start.setFullYear(start.getFullYear() - 10); // 10 years
break;
}
return { start, end };
}
}

View file

@ -0,0 +1,118 @@
export interface CandlestickData {
time: number; // Unix timestamp in seconds
open: number;
high: number;
low: number;
close: number;
volume?: number;
}
export interface LineData {
time: number; // Unix timestamp in seconds
value: number;
}
export interface VolumeData {
time: number; // Unix timestamp in seconds
value: number;
color?: string;
}
export type ChartType = 'candlestick' | 'line' | 'area' | 'bar';
export type ChartInterval =
| '1m'
| '5m'
| '15m'
| '30m'
| '1h'
| '4h'
| '1d'
| '1w'
| '1M';
export interface ChartConfig {
symbol: string;
interval: ChartInterval;
chartType: ChartType;
showVolume: boolean;
indicators: string[];
theme: 'light' | 'dark';
}
export interface MarketData {
symbol: string;
name: string;
price: number;
change: number;
changePercent: number;
volume: number;
high: number;
low: number;
open: number;
previousClose: number;
timestamp: string;
}
export interface ChartIndicator {
id: string;
name: string;
type: 'overlay' | 'separate';
parameters: Record<string, number>;
visible: boolean;
}
// TradingView Lightweight Charts specific types
export interface ChartOptions {
width?: number;
height?: number;
layout?: {
background?: {
type: 'solid' | 'gradient';
color?: string;
};
textColor?: string;
fontSize?: number;
};
grid?: {
vertLines?: {
color?: string;
style?: number;
visible?: boolean;
};
horzLines?: {
color?: string;
style?: number;
visible?: boolean;
};
};
crosshair?: {
mode?: number;
vertLine?: {
color?: string;
width?: number;
style?: number;
visible?: boolean;
labelVisible?: boolean;
};
horzLine?: {
color?: string;
width?: number;
style?: number;
visible?: boolean;
labelVisible?: boolean;
};
};
timeScale?: {
rightOffset?: number;
barSpacing?: number;
fixLeftEdge?: boolean;
lockVisibleTimeRangeOnResize?: boolean;
rightBarStaysOnScroll?: boolean;
borderVisible?: boolean;
borderColor?: string;
visible?: boolean;
timeVisible?: boolean;
secondsVisible?: boolean;
};
}

View file

@ -0,0 +1,258 @@
import type { CandlestickData, LineData } from '../types';
// Simple Moving Average
export function calculateSMA(data: CandlestickData[], period: number): LineData[] {
const result: LineData[] = [];
for (let i = period - 1; i < data.length; i++) {
let sum = 0;
for (let j = 0; j < period; j++) {
sum += data[i - j].close;
}
result.push({
time: data[i].time,
value: parseFloat((sum / period).toFixed(2)),
});
}
return result;
}
// Exponential Moving Average
export function calculateEMA(data: CandlestickData[], period: number): LineData[] {
const result: LineData[] = [];
const multiplier = 2 / (period + 1);
// Start with SMA for the first period
let sum = 0;
for (let i = 0; i < period; i++) {
sum += data[i].close;
}
let ema = sum / period;
result.push({
time: data[period - 1].time,
value: parseFloat(ema.toFixed(2)),
});
// Calculate EMA for the rest
for (let i = period; i < data.length; i++) {
ema = (data[i].close - ema) * multiplier + ema;
result.push({
time: data[i].time,
value: parseFloat(ema.toFixed(2)),
});
}
return result;
}
// Bollinger Bands
export function calculateBollingerBands(data: CandlestickData[], period: number = 20, stdDev: number = 2) {
const sma = calculateSMA(data, period);
const upper: LineData[] = [];
const lower: LineData[] = [];
for (let i = period - 1; i < data.length; i++) {
let sumSquaredDiff = 0;
const smaValue = sma[i - (period - 1)].value;
for (let j = 0; j < period; j++) {
const diff = data[i - j].close - smaValue;
sumSquaredDiff += diff * diff;
}
const standardDeviation = Math.sqrt(sumSquaredDiff / period);
const upperBand = smaValue + (standardDeviation * stdDev);
const lowerBand = smaValue - (standardDeviation * stdDev);
upper.push({
time: data[i].time,
value: parseFloat(upperBand.toFixed(2)),
});
lower.push({
time: data[i].time,
value: parseFloat(lowerBand.toFixed(2)),
});
}
return { upper, middle: sma, lower };
}
// Relative Strength Index (RSI)
export function calculateRSI(data: CandlestickData[], period: number = 14): LineData[] {
const result: LineData[] = [];
if (data.length < period + 1) return result;
// Calculate initial average gain/loss
let avgGain = 0;
let avgLoss = 0;
for (let i = 1; i <= period; i++) {
const change = data[i].close - data[i - 1].close;
if (change > 0) {
avgGain += change;
} else {
avgLoss += Math.abs(change);
}
}
avgGain /= period;
avgLoss /= period;
// Calculate RSI for each period
for (let i = period; i < data.length; i++) {
const change = data[i].close - data[i - 1].close;
const gain = change > 0 ? change : 0;
const loss = change < 0 ? Math.abs(change) : 0;
avgGain = (avgGain * (period - 1) + gain) / period;
avgLoss = (avgLoss * (period - 1) + loss) / period;
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
const rsi = 100 - (100 / (1 + rs));
result.push({
time: data[i].time,
value: parseFloat(rsi.toFixed(2)),
});
}
return result;
}
// MACD (Moving Average Convergence Divergence)
export function calculateMACD(
data: CandlestickData[],
fastPeriod: number = 12,
slowPeriod: number = 26,
signalPeriod: number = 9
) {
const emaFast = calculateEMA(data, fastPeriod);
const emaSlow = calculateEMA(data, slowPeriod);
const macdLine: LineData[] = [];
const macdData: number[] = [];
// Calculate MACD line
for (let i = 0; i < Math.min(emaFast.length, emaSlow.length); i++) {
const fastIdx = emaFast.findIndex(d => d.time === emaSlow[i].time);
if (fastIdx !== -1) {
const macdValue = emaFast[fastIdx].value - emaSlow[i].value;
macdLine.push({
time: emaSlow[i].time,
value: parseFloat(macdValue.toFixed(2)),
});
macdData.push(macdValue);
}
}
// Calculate signal line (EMA of MACD)
const signalLine: LineData[] = [];
if (macdData.length >= signalPeriod) {
const multiplier = 2 / (signalPeriod + 1);
let ema = macdData.slice(0, signalPeriod).reduce((a, b) => a + b, 0) / signalPeriod;
signalLine.push({
time: macdLine[signalPeriod - 1].time,
value: parseFloat(ema.toFixed(2)),
});
for (let i = signalPeriod; i < macdData.length; i++) {
ema = (macdData[i] - ema) * multiplier + ema;
signalLine.push({
time: macdLine[i].time,
value: parseFloat(ema.toFixed(2)),
});
}
}
// Calculate histogram
const histogram: LineData[] = [];
for (let i = 0; i < signalLine.length; i++) {
const macdIdx = macdLine.findIndex(d => d.time === signalLine[i].time);
if (macdIdx !== -1) {
histogram.push({
time: signalLine[i].time,
value: parseFloat((macdLine[macdIdx].value - signalLine[i].value).toFixed(2)),
});
}
}
return { macdLine, signalLine, histogram };
}
// Volume Weighted Average Price (VWAP)
export function calculateVWAP(data: CandlestickData[]): LineData[] {
const result: LineData[] = [];
let cumulativeTPV = 0; // Cumulative Typical Price × Volume
let cumulativeVolume = 0;
for (let i = 0; i < data.length; i++) {
const typicalPrice = (data[i].high + data[i].low + data[i].close) / 3;
const volume = data[i].volume || 0;
cumulativeTPV += typicalPrice * volume;
cumulativeVolume += volume;
if (cumulativeVolume > 0) {
result.push({
time: data[i].time,
value: parseFloat((cumulativeTPV / cumulativeVolume).toFixed(2)),
});
}
}
return result;
}
// Stochastic Oscillator
export function calculateStochastic(data: CandlestickData[], period: number = 14, smoothK: number = 3, smoothD: number = 3) {
const kValues: LineData[] = [];
const dValues: LineData[] = [];
// Calculate %K
for (let i = period - 1; i < data.length; i++) {
let lowestLow = data[i].low;
let highestHigh = data[i].high;
for (let j = 1; j < period; j++) {
lowestLow = Math.min(lowestLow, data[i - j].low);
highestHigh = Math.max(highestHigh, data[i - j].high);
}
const k = ((data[i].close - lowestLow) / (highestHigh - lowestLow)) * 100;
kValues.push({
time: data[i].time,
value: parseFloat(k.toFixed(2)),
});
}
// Smooth %K if needed
const smoothedK = smoothK > 1 ? calculateSMAFromLineData(kValues, smoothK) : kValues;
// Calculate %D (SMA of %K)
const smoothedD = calculateSMAFromLineData(smoothedK, smoothD);
return { k: smoothedK, d: smoothedD };
}
// Helper function to calculate SMA from LineData
function calculateSMAFromLineData(data: LineData[], period: number): LineData[] {
const result: LineData[] = [];
for (let i = period - 1; i < data.length; i++) {
let sum = 0;
for (let j = 0; j < period; j++) {
sum += data[i - j].value;
}
result.push({
time: data[i].time,
value: parseFloat((sum / period).toFixed(2)),
});
}
return result;
}

View file

@ -8,6 +8,8 @@ import {
HomeIcon,
PresentationChartLineIcon,
ServerStackIcon,
BeakerIcon,
CurrencyDollarIcon,
} from '@heroicons/react/24/outline';
export interface NavigationItem {
@ -19,10 +21,18 @@ export interface NavigationItem {
export const navigation: NavigationItem[] = [
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{ name: 'Exchanges', href: '/exchanges', icon: BuildingLibraryIcon },
{
name: 'Market Data',
icon: CurrencyDollarIcon,
children: [
{ name: 'Charts', href: '/charts', icon: ChartBarIcon },
{ name: 'Exchanges', href: '/exchanges', icon: BuildingLibraryIcon },
],
},
{ name: 'Portfolio', href: '/portfolio', icon: ChartBarIcon },
{ name: 'Strategies', href: '/strategies', icon: DocumentTextIcon },
{ name: 'Analytics', href: '/analytics', icon: PresentationChartLineIcon },
{ name: 'Backtest', href: '/backtest', icon: BeakerIcon },
{
name: 'System',
icon: ServerStackIcon,

View file

@ -1,4 +1,5 @@
import {
BeakerIcon,
BuildingOfficeIcon,
ChartBarIcon,
CogIcon,
@ -27,6 +28,12 @@ export const navigation = [
icon: ChartBarIcon,
current: false,
},
{
name: 'Backtest',
href: '/backtest',
icon: BeakerIcon,
current: false,
},
{
name: 'Exchanges',
href: '/exchanges',