Initial commit: Gold Trading Simulator with AI-powered analysis

This commit is contained in:
Krikorios
2025-11-16 00:50:04 +02:00
commit 72c1d3adb7
128 changed files with 16232 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
import { Brain, TrendingUp, TrendingDown, Minus, AlertTriangle } from 'lucide-react';
import type { AIAnalysis } from '@/types';
interface AIAnalysisPanelProps {
analysis: AIAnalysis | null;
isLoading: boolean;
}
export default function AIAnalysisPanel({ analysis, isLoading }: AIAnalysisPanelProps) {
if (isLoading) {
return (
<div className="card">
<div className="flex items-center gap-3 mb-4">
<Brain className="w-6 h-6 text-blue-500" />
<h3 className="text-lg font-semibold">AI Analysis</h3>
</div>
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-3 text-gray-400">Analyzing market conditions...</span>
</div>
</div>
);
}
if (!analysis) {
return (
<div className="card">
<div className="flex items-center gap-3 mb-4">
<Brain className="w-6 h-6 text-blue-500" />
<h3 className="text-lg font-semibold">AI Analysis</h3>
</div>
<p className="text-gray-400 text-center py-8">
Click "AI Analysis" to get intelligent market insights
</p>
</div>
);
}
const getRecommendationIcon = () => {
switch (analysis.recommendation) {
case 'BUY':
return <TrendingUp className="w-6 h-6 text-green-500" />;
case 'SELL':
return <TrendingDown className="w-6 h-6 text-red-500" />;
case 'HOLD':
return <Minus className="w-6 h-6 text-yellow-500" />;
}
};
const getRecommendationColor = () => {
switch (analysis.recommendation) {
case 'BUY':
return 'text-green-500';
case 'SELL':
return 'text-red-500';
case 'HOLD':
return 'text-yellow-500';
}
};
const getRiskColor = () => {
switch (analysis.riskLevel) {
case 'LOW':
return 'bg-green-500/20 text-green-500';
case 'MEDIUM':
return 'bg-yellow-500/20 text-yellow-500';
case 'HIGH':
return 'bg-red-500/20 text-red-500';
}
};
return (
<div className="card">
<div className="flex items-center gap-3 mb-4">
<Brain className="w-6 h-6 text-blue-500" />
<h3 className="text-lg font-semibold">AI Analysis</h3>
</div>
<div className="space-y-4">
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
{getRecommendationIcon()}
<div>
<p className="text-sm text-gray-400">Recommendation</p>
<p className={`text-xl font-bold ${getRecommendationColor()}`}>
{analysis.recommendation}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-gray-400">Confidence</p>
<p className="text-xl font-bold">{analysis.confidence}%</p>
</div>
</div>
<div className="w-full bg-dark-surface rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${analysis.confidence}%` }}
/>
</div>
</div>
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-gray-400" />
<p className="text-sm font-semibold">Risk Level</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getRiskColor()}`}>
{analysis.riskLevel}
</span>
</div>
<div className="bg-dark-bg rounded-lg p-4">
<h4 className="font-semibold mb-2">Reasoning</h4>
<p className="text-sm text-gray-300 leading-relaxed">{analysis.reasoning}</p>
</div>
{(analysis.supportResistance.support.length > 0 ||
analysis.supportResistance.resistance.length > 0) && (
<div className="bg-dark-bg rounded-lg p-4">
<h4 className="font-semibold mb-3">Key Levels</h4>
<div className="space-y-3">
{analysis.supportResistance.resistance.length > 0 && (
<div>
<p className="text-xs text-gray-400 mb-1">Resistance</p>
<div className="flex flex-wrap gap-2">
{analysis.supportResistance.resistance.map((level, idx) => (
<span
key={idx}
className="px-2 py-1 bg-red-500/20 text-red-500 rounded text-sm"
>
${level.toFixed(2)}
</span>
))}
</div>
</div>
)}
{analysis.supportResistance.support.length > 0 && (
<div>
<p className="text-xs text-gray-400 mb-1">Support</p>
<div className="flex flex-wrap gap-2">
{analysis.supportResistance.support.map((level, idx) => (
<span
key={idx}
className="px-2 py-1 bg-green-500/20 text-green-500 rounded text-sm"
>
${level.toFixed(2)}
</span>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react'
import { accountApi } from '@/services/api'
export default function AccountPositionsPanel() {
const [account, setAccount] = useState<any | null>(null)
const [positions, setPositions] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let mounted = true
;(async () => {
try {
setLoading(true)
const [a, p] = await Promise.all([accountApi.getAccount(), accountApi.getPositions()])
if (mounted) { setAccount(a); setPositions(p) }
} catch (e: any) {
setError(e?.message || 'Failed to load account')
} finally {
setLoading(false)
}
})()
return () => { mounted = false }
}, [])
return (
<div className="card">
<div className="title">Account & Positions</div>
{loading && <div className="text-gray-400">Loading</div>}
{error && <div className="text-red-500">{error}</div>}
{!loading && !error && account && (
<div style={{ display: 'grid', gap: 12 }}>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Metric label="Cash" value={fmt(account.cash)} />
<Metric label="Equity" value={fmt(account.equity)} />
<Metric label="Initial" value={fmt(account.initial_capital)} />
</div>
<div>
<div className="text-gray-300 font-semibold mb-2">Positions</div>
{positions.length === 0 ? (
<div className="text-gray-400">No open positions</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-400">
<th>Symbol</th>
<th>Qty</th>
<th>Avg Price</th>
<th>Last Price</th>
<th>Market Value</th>
</tr>
</thead>
<tbody>
{positions.map((p, i) => (
<tr key={i} className="border-t border-dark-border">
<td>{p.symbol}</td>
<td>{p.quantity}</td>
<td>{fmt(p.avg_price)}</td>
<td>{p.last_price ? fmt(p.last_price) : '-'}</td>
<td>{fmt(p.market_value)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
)
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="card" style={{ padding: 12 }}>
<div className="text-gray-400 text-xs">{label}</div>
<div className="text-gray-100 text-lg font-semibold">{value}</div>
</div>
)
}
function fmt(n: number) {
return n.toLocaleString(undefined, { maximumFractionDigits: 2 })
}
@@ -0,0 +1,272 @@
import { useMemo } from 'react';
import {
TrendingUp,
TrendingDown,
Target,
Shield,
BarChart3,
AlertCircle,
} from 'lucide-react';
import type { Trade, Portfolio } from '@/types';
import {
calculateWinRate,
calculateSharpeRatio,
calculateMaxDrawdown,
formatPrice,
formatPercent,
} from '@/utils/indicators';
interface AdvancedAnalyticsProps {
portfolio: Portfolio;
trades: Trade[];
}
export default function AdvancedAnalytics({ portfolio, trades }: AdvancedAnalyticsProps) {
const analytics = useMemo(() => {
if (trades.length === 0) {
return {
winRate: 0,
totalTrades: 0,
winningTrades: 0,
losingTrades: 0,
avgWin: 0,
avgLoss: 0,
largestWin: 0,
largestLoss: 0,
profitFactor: 0,
sharpeRatio: 0,
maxDrawdown: 0,
avgHoldTime: 0,
riskRewardRatio: 0,
};
}
const completedTrades = trades.filter((t) => t.pnl !== undefined);
const winningTrades = completedTrades.filter((t) => t.pnl! > 0);
const losingTrades = completedTrades.filter((t) => t.pnl! <= 0);
const totalWins = winningTrades.reduce((sum, t) => sum + t.pnl!, 0);
const totalLosses = Math.abs(losingTrades.reduce((sum, t) => sum + t.pnl!, 0));
const avgWin = winningTrades.length > 0 ? totalWins / winningTrades.length : 0;
const avgLoss = losingTrades.length > 0 ? totalLosses / losingTrades.length : 0;
const largestWin = winningTrades.length > 0 ? Math.max(...winningTrades.map((t) => t.pnl!)) : 0;
const largestLoss = losingTrades.length > 0 ? Math.min(...losingTrades.map((t) => t.pnl!)) : 0;
const profitFactor = totalLosses > 0 ? totalWins / totalLosses : totalWins > 0 ? Infinity : 0;
// Calculate returns for Sharpe ratio
const returns = completedTrades.map((t) => (t.pnl! / (t.quantity * t.price)) * 100);
const sharpeRatio = calculateSharpeRatio(returns);
// Calculate equity curve for max drawdown
const equity: number[] = [portfolio.initialCapital];
let currentEquity = portfolio.initialCapital;
for (const trade of completedTrades) {
currentEquity += trade.pnl!;
equity.push(currentEquity);
}
const maxDrawdown = calculateMaxDrawdown(equity);
const riskRewardRatio = avgLoss > 0 ? avgWin / avgLoss : 0;
return {
winRate: calculateWinRate(completedTrades),
totalTrades: completedTrades.length,
winningTrades: winningTrades.length,
losingTrades: losingTrades.length,
avgWin,
avgLoss,
largestWin,
largestLoss,
profitFactor,
sharpeRatio,
maxDrawdown,
avgHoldTime: 0, // Would need timestamp tracking
riskRewardRatio,
};
}, [trades, portfolio]);
const getQualityRating = (value: number, type: string): { color: string; text: string } => {
switch (type) {
case 'winRate':
if (value >= 60) return { color: 'text-green-500', text: 'Excellent' };
if (value >= 50) return { color: 'text-blue-500', text: 'Good' };
if (value >= 40) return { color: 'text-yellow-500', text: 'Average' };
return { color: 'text-red-500', text: 'Poor' };
case 'sharpe':
if (value >= 2) return { color: 'text-green-500', text: 'Excellent' };
if (value >= 1) return { color: 'text-blue-500', text: 'Good' };
if (value >= 0.5) return { color: 'text-yellow-500', text: 'Average' };
return { color: 'text-red-500', text: 'Poor' };
case 'profitFactor':
if (value >= 2) return { color: 'text-green-500', text: 'Excellent' };
if (value >= 1.5) return { color: 'text-blue-500', text: 'Good' };
if (value >= 1) return { color: 'text-yellow-500', text: 'Average' };
return { color: 'text-red-500', text: 'Poor' };
case 'maxDrawdown':
if (value <= 10) return { color: 'text-green-500', text: 'Excellent' };
if (value <= 20) return { color: 'text-blue-500', text: 'Good' };
if (value <= 30) return { color: 'text-yellow-500', text: 'Average' };
return { color: 'text-red-500', text: 'High Risk' };
default:
return { color: 'text-gray-500', text: 'N/A' };
}
};
if (trades.length === 0) {
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
Advanced Analytics
</h3>
<p className="text-center text-gray-400 py-8">
No trades yet. Execute some trades to see detailed analytics.
</p>
</div>
);
}
const winRateQuality = getQualityRating(analytics.winRate, 'winRate');
const sharpeQuality = getQualityRating(analytics.sharpeRatio, 'sharpe');
const profitFactorQuality = getQualityRating(analytics.profitFactor, 'profitFactor');
const drawdownQuality = getQualityRating(analytics.maxDrawdown, 'maxDrawdown');
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-blue-500" />
Advanced Analytics
</h3>
<div className="grid grid-cols-2 gap-4">
{/* Win Rate */}
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<Target className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">Win Rate</span>
</div>
<p className="text-2xl font-bold">{analytics.winRate}%</p>
<p className={`text-xs ${winRateQuality.color}`}>{winRateQuality.text}</p>
<p className="text-xs text-gray-500 mt-1">
{analytics.winningTrades}W / {analytics.losingTrades}L
</p>
</div>
{/* Profit Factor */}
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">Profit Factor</span>
</div>
<p className="text-2xl font-bold">
{analytics.profitFactor === Infinity
? '∞'
: analytics.profitFactor.toFixed(2)}
</p>
<p className={`text-xs ${profitFactorQuality.color}`}>{profitFactorQuality.text}</p>
<p className="text-xs text-gray-500 mt-1">Wins / Losses ratio</p>
</div>
{/* Sharpe Ratio */}
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<Shield className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">Sharpe Ratio</span>
</div>
<p className="text-2xl font-bold">{analytics.sharpeRatio.toFixed(2)}</p>
<p className={`text-xs ${sharpeQuality.color}`}>{sharpeQuality.text}</p>
<p className="text-xs text-gray-500 mt-1">Risk-adjusted returns</p>
</div>
{/* Max Drawdown */}
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingDown className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">Max Drawdown</span>
</div>
<p className="text-2xl font-bold text-red-500">-{analytics.maxDrawdown}%</p>
<p className={`text-xs ${drawdownQuality.color}`}>{drawdownQuality.text}</p>
<p className="text-xs text-gray-500 mt-1">Largest peak-to-trough</p>
</div>
{/* Average Win */}
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-green-500" />
<span className="text-sm text-gray-400">Avg Win</span>
</div>
<p className="text-xl font-semibold text-green-500">
{formatPrice(analytics.avgWin)}
</p>
<p className="text-xs text-gray-500 mt-1">
Largest: {formatPrice(analytics.largestWin)}
</p>
</div>
{/* Average Loss */}
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<TrendingDown className="w-4 h-4 text-red-500" />
<span className="text-sm text-gray-400">Avg Loss</span>
</div>
<p className="text-xl font-semibold text-red-500">
{formatPrice(analytics.avgLoss)}
</p>
<p className="text-xs text-gray-500 mt-1">
Largest: {formatPrice(Math.abs(analytics.largestLoss))}
</p>
</div>
{/* Risk/Reward Ratio */}
<div className="bg-dark-bg rounded-lg p-4 col-span-2">
<div className="flex items-center gap-2 mb-1">
<AlertCircle className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">Risk/Reward Ratio</span>
</div>
<p className="text-2xl font-bold">
1:{analytics.riskRewardRatio.toFixed(2)}
</p>
<p className="text-xs text-gray-500 mt-1">
Average win vs average loss per trade
</p>
</div>
</div>
{/* Performance Summary */}
<div className="mt-4 p-4 bg-dark-bg rounded-lg border border-dark-border">
<h4 className="font-semibold mb-2">Performance Summary</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Total Trades:</span>
<span className="font-medium">{analytics.totalTrades}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Net P&L:</span>
<span
className={`font-semibold ${
portfolio.totalPnl >= 0 ? 'text-green-500' : 'text-red-500'
}`}
>
{formatPrice(portfolio.totalPnl)} ({formatPercent(portfolio.totalPnlPercent)})
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Capital Efficiency:</span>
<span className="font-medium">
{((portfolio.totalValue / portfolio.initialCapital - 1) * 100).toFixed(2)}%
</span>
</div>
</div>
</div>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import { useEffect, useState } from 'react';
import {
Bell,
AlertTriangle,
TrendingUp,
TrendingDown,
Zap,
Shield,
Calendar,
X,
} from 'lucide-react';
import type { AlertsData, Alert, AlertType, AlertSeverity } from '@/types';
import { newsApi } from '@/services/api';
export default function AlertsPanel() {
const [alertsData, setAlertsData] = useState<AlertsData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState<'ALL' | AlertSeverity>('ALL');
const loadAlerts = async () => {
try {
setIsLoading(true);
const data = await newsApi.getAlerts(50);
setAlertsData(data);
} catch (error) {
console.error('Error loading alerts:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadAlerts();
// Auto-refresh every minute
const interval = setInterval(loadAlerts, 60000);
return () => clearInterval(interval);
}, []);
const filteredAlerts = alertsData?.alerts.filter(
(alert) => filter === 'ALL' || alert.severity === filter
) || [];
return (
<div className="card">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="relative">
<Bell className="w-6 h-6 text-yellow-500" />
{alertsData && alertsData.critical_count > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
{alertsData.critical_count}
</span>
)}
</div>
<div>
<h3 className="text-lg font-semibold">Alerts</h3>
{alertsData && (
<p className="text-xs text-gray-400">
{alertsData.alerts.length} alerts {alertsData.critical_count} critical
</p>
)}
</div>
</div>
</div>
<div className="mb-4">
<div className="grid grid-cols-5 gap-2 text-sm">
<button
onClick={() => setFilter('ALL')}
className={`px-2 py-1 rounded-md transition-colors ${
filter === 'ALL' ? 'bg-blue-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
All
</button>
<button
onClick={() => setFilter('CRITICAL')}
className={`px-2 py-1 rounded-md transition-colors ${
filter === 'CRITICAL' ? 'bg-red-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
Critical
</button>
<button
onClick={() => setFilter('HIGH')}
className={`px-2 py-1 rounded-md transition-colors ${
filter === 'HIGH' ? 'bg-orange-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
High
</button>
<button
onClick={() => setFilter('MEDIUM')}
className={`px-2 py-1 rounded-md transition-colors ${
filter === 'MEDIUM' ? 'bg-yellow-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
Medium
</button>
<button
onClick={() => setFilter('LOW')}
className={`px-2 py-1 rounded-md transition-colors ${
filter === 'LOW' ? 'bg-gray-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
Low
</button>
</div>
</div>
<div className="space-y-2 max-h-80 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-yellow-500"></div>
</div>
) : filteredAlerts.length > 0 ? (
filteredAlerts.map((alert) => (
<AlertCard key={alert.id} alert={alert} />
))
) : (
<p className="text-center text-gray-400 py-8">No alerts to display</p>
)}
</div>
</div>
);
}
function AlertCard({ alert }: { alert: Alert }) {
const getAlertIcon = (type: AlertType) => {
switch (type) {
case 'PRICE_SPIKE':
return <TrendingUp className="w-5 h-5 text-green-500" />;
case 'PRICE_DROP':
return <TrendingDown className="w-5 h-5 text-red-500" />;
case 'NEWS_BREAKING':
return <Zap className="w-5 h-5 text-blue-500" />;
case 'SUPPORT_BREACH':
case 'RESISTANCE_BREACH':
return <Shield className="w-5 h-5 text-orange-500" />;
case 'HIGH_VOLATILITY':
return <AlertTriangle className="w-5 h-5 text-yellow-500" />;
case 'ECONOMIC_EVENT':
return <Calendar className="w-5 h-5 text-purple-500" />;
default:
return <Bell className="w-5 h-5 text-gray-500" />;
}
};
const getSeverityColor = (severity: AlertSeverity) => {
switch (severity) {
case 'CRITICAL':
return 'bg-red-500/20 border-red-500';
case 'HIGH':
return 'bg-orange-500/20 border-orange-500';
case 'MEDIUM':
return 'bg-yellow-500/20 border-yellow-500';
case 'LOW':
return 'bg-blue-500/20 border-blue-500';
}
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffMs / 60000);
if (diffSecs < 60) {
return `${diffSecs}s ago`;
} else if (diffMins < 60) {
return `${diffMins}m ago`;
} else {
return date.toLocaleTimeString();
}
};
return (
<div
className={`p-3 rounded-md border-l-4 ${getSeverityColor(
alert.severity
)} hover:bg-dark-hover transition-colors`}
>
<div className="flex items-start gap-3">
{getAlertIcon(alert.type)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h4 className="font-semibold text-sm">{alert.title}</h4>
<span className="text-xs text-gray-500">{formatTime(alert.timestamp)}</span>
</div>
<p className="text-xs text-gray-400 mb-2">{alert.message}</p>
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded ${getSeverityColor(alert.severity)}`}>
{alert.severity}
</span>
{alert.price && (
<span className="text-xs px-2 py-0.5 rounded bg-dark-bg text-gray-400">
${alert.price.toFixed(2)}
</span>
)}
{alert.change_percent && (
<span
className={`text-xs px-2 py-0.5 rounded ${
alert.change_percent > 0
? 'bg-green-500/20 text-green-500'
: 'bg-red-500/20 text-red-500'
}`}
>
{alert.change_percent > 0 ? '+' : ''}
{alert.change_percent.toFixed(2)}%
</span>
)}
{alert.action_required && (
<span className="text-xs px-2 py-0.5 rounded bg-red-500/20 text-red-500 font-semibold">
ACTION REQUIRED
</span>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,187 @@
import { useState } from 'react';
import { Settings, X } from 'lucide-react';
import type { TabCustomization } from '@/types';
interface ComponentSettingsProps {
customization?: TabCustomization;
onUpdate: (customization: TabCustomization) => void;
availableSettings?: {
refreshRate?: boolean;
autoRefresh?: boolean;
displayMode?: string[];
filters?: Record<string, any>;
theme?: boolean;
};
}
export default function ComponentSettings({
customization = {},
onUpdate,
availableSettings = {},
}: ComponentSettingsProps) {
const [isOpen, setIsOpen] = useState(false);
const [localSettings, setLocalSettings] = useState<TabCustomization>(customization);
const handleSave = () => {
onUpdate(localSettings);
setIsOpen(false);
};
const handleCancel = () => {
setLocalSettings(customization);
setIsOpen(false);
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="p-1.5 hover:bg-dark-hover rounded transition-colors opacity-0 group-hover:opacity-100"
title="Component Settings"
>
<Settings className="w-4 h-4 text-gray-400" />
</button>
);
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-dark-card rounded-lg shadow-xl max-w-md w-full">
{/* Header */}
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
<h3 className="text-lg font-semibold">Component Settings</h3>
<button onClick={handleCancel} className="text-gray-400 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
{/* Settings */}
<div className="p-4 space-y-4">
{availableSettings.autoRefresh && (
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Auto Refresh</label>
<input
type="checkbox"
checked={localSettings.autoRefresh || false}
onChange={(e) =>
setLocalSettings({ ...localSettings, autoRefresh: e.target.checked })
}
className="rounded"
/>
</div>
)}
{availableSettings.refreshRate && localSettings.autoRefresh && (
<div>
<label className="text-sm font-medium block mb-2">
Refresh Rate (seconds)
</label>
<input
type="number"
min={10}
max={3600}
value={localSettings.refreshRate || 60}
onChange={(e) =>
setLocalSettings({ ...localSettings, refreshRate: Number(e.target.value) })
}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg"
/>
</div>
)}
{availableSettings.displayMode && availableSettings.displayMode.length > 0 && (
<div>
<label className="text-sm font-medium block mb-2">Display Mode</label>
<select
value={localSettings.displayMode || 'default'}
onChange={(e) =>
setLocalSettings({ ...localSettings, displayMode: e.target.value })
}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg"
>
{availableSettings.displayMode.map((mode) => (
<option key={mode} value={mode}>
{mode.charAt(0).toUpperCase() + mode.slice(1)}
</option>
))}
</select>
</div>
)}
{availableSettings.theme && (
<div>
<label className="text-sm font-medium block mb-2">Theme</label>
<select
value={localSettings.theme || 'default'}
onChange={(e) =>
setLocalSettings({
...localSettings,
theme: e.target.value as 'default' | 'compact' | 'detailed',
})
}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg"
>
<option value="default">Default</option>
<option value="compact">Compact</option>
<option value="detailed">Detailed</option>
</select>
</div>
)}
{availableSettings.filters && Object.keys(availableSettings.filters).length > 0 && (
<div className="border-t border-gray-700 pt-4">
<label className="text-sm font-medium block mb-2">Filters</label>
{Object.entries(availableSettings.filters).map(([key, options]) => (
<div key={key} className="mb-3">
<label className="text-xs text-gray-400 block mb-1">
{key.charAt(0).toUpperCase() + key.slice(1)}
</label>
{Array.isArray(options) ? (
<select
value={localSettings.filters?.[key] || options[0]}
onChange={(e) =>
setLocalSettings({
...localSettings,
filters: { ...localSettings.filters, [key]: e.target.value },
})
}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg text-sm"
>
{options.map((opt: string) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : (
<input
type="text"
value={localSettings.filters?.[key] || ''}
onChange={(e) =>
setLocalSettings({
...localSettings,
filters: { ...localSettings.filters, [key]: e.target.value },
})
}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg text-sm"
/>
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-700 flex items-center justify-end gap-2">
<button onClick={handleCancel} className="btn-secondary">
Cancel
</button>
<button onClick={handleSave} className="btn-primary">
Save
</button>
</div>
</div>
</div>
);
}
+416
View File
@@ -0,0 +1,416 @@
import { useState, useEffect } from 'react';
import { CheckCircle2, Circle, Clock, TrendingUp, Calendar, BarChart3, AlertCircle } from 'lucide-react';
interface ChecklistItem {
id: string;
title: string;
description: string;
completed: boolean;
category: 'pre-market' | 'active-trading' | 'post-market';
}
interface DailyChecklistProps {
onComplete?: (completedCount: number, totalCount: number) => void;
}
const DEFAULT_CHECKLIST: ChecklistItem[] = [
// Pre-Market (Before Trading)
{
id: 'review-calendar',
title: 'Check Economic Calendar',
description: 'Review today\'s economic events and their potential impact on gold',
completed: false,
category: 'pre-market',
},
{
id: 'check-news',
title: 'Scan Market News',
description: 'Read overnight news, geopolitical events, Fed statements',
completed: false,
category: 'pre-market',
},
{
id: 'analyze-sentiment',
title: 'Analyze Market Sentiment',
description: 'Check overall market sentiment and gold-specific sentiment',
completed: false,
category: 'pre-market',
},
{
id: 'identify-levels',
title: 'Identify Key Levels',
description: 'Mark support/resistance, pivot points, previous high/low',
completed: false,
category: 'pre-market',
},
{
id: 'set-plan',
title: 'Create Trading Plan',
description: 'Set daily target, max loss, entry/exit criteria',
completed: false,
category: 'pre-market',
},
{
id: 'check-risk',
title: 'Review Risk Parameters',
description: 'Confirm position sizing, stop-loss levels, risk per trade',
completed: false,
category: 'pre-market',
},
{
id: 'mental-prep',
title: 'Mental Preparation',
description: 'Review trading rules, stay disciplined, manage emotions',
completed: false,
category: 'pre-market',
},
// Active Trading
{
id: 'monitor-price',
title: 'Monitor Price Action',
description: 'Watch for entry signals based on your plan',
completed: false,
category: 'active-trading',
},
{
id: 'follow-plan',
title: 'Execute According to Plan',
description: 'Only take trades that match your criteria',
completed: false,
category: 'active-trading',
},
{
id: 'manage-positions',
title: 'Manage Open Positions',
description: 'Adjust stops, take partials, follow exit rules',
completed: false,
category: 'active-trading',
},
{
id: 'track-news-live',
title: 'Track Breaking News',
description: 'Monitor for unexpected events that could impact positions',
completed: false,
category: 'active-trading',
},
{
id: 'record-trades',
title: 'Log Trades in Real-Time',
description: 'Record entry reasons, emotions, and setup quality',
completed: false,
category: 'active-trading',
},
// Post-Market (After Trading)
{
id: 'review-trades',
title: 'Review All Trades',
description: 'Analyze winners and losers, identify patterns',
completed: false,
category: 'post-market',
},
{
id: 'update-journal',
title: 'Complete Trading Journal',
description: 'Document lessons learned, emotional state, market conditions',
completed: false,
category: 'post-market',
},
{
id: 'analyze-performance',
title: 'Analyze Daily Performance',
description: 'Calculate P&L, win rate, risk-reward, adherence to plan',
completed: false,
category: 'post-market',
},
{
id: 'update-levels',
title: 'Update Key Levels',
description: 'Mark new support/resistance for tomorrow',
completed: false,
category: 'post-market',
},
{
id: 'plan-tomorrow',
title: 'Preview Tomorrow',
description: 'Check upcoming economic events and prepare strategy',
completed: false,
category: 'post-market',
},
{
id: 'set-alerts',
title: 'Set Price Alerts',
description: 'Configure alerts for overnight price movements',
completed: false,
category: 'post-market',
},
];
export default function DailyChecklist({ onComplete }: DailyChecklistProps) {
const [checklist, setChecklist] = useState<ChecklistItem[]>(() => {
const stored = localStorage.getItem('daily-trading-checklist');
if (stored) {
try {
return JSON.parse(stored);
} catch {
return DEFAULT_CHECKLIST;
}
}
return DEFAULT_CHECKLIST;
});
const [activeCategory, setActiveCategory] = useState<'pre-market' | 'active-trading' | 'post-market'>('pre-market');
const [showCompleted, setShowCompleted] = useState(true);
// Save to localStorage whenever checklist changes
useEffect(() => {
localStorage.setItem('daily-trading-checklist', JSON.stringify(checklist));
const completed = checklist.filter(item => item.completed).length;
const total = checklist.length;
if (onComplete) {
onComplete(completed, total);
}
}, [checklist, onComplete]);
// Reset checklist at midnight
useEffect(() => {
const checkReset = () => {
const lastReset = localStorage.getItem('checklist-last-reset');
const today = new Date().toDateString();
if (lastReset !== today) {
setChecklist(DEFAULT_CHECKLIST);
localStorage.setItem('checklist-last-reset', today);
}
};
checkReset();
const interval = setInterval(checkReset, 60000); // Check every minute
return () => clearInterval(interval);
}, []);
const handleToggle = (id: string) => {
setChecklist(prev =>
prev.map(item =>
item.id === id ? { ...item, completed: !item.completed } : item
)
);
};
const handleResetAll = () => {
if (confirm('Reset all checklist items? This will mark all as incomplete.')) {
setChecklist(DEFAULT_CHECKLIST);
}
};
const getCategoryItems = (category: string) => {
return checklist.filter(item => item.category === category);
};
const getCategoryProgress = (category: string) => {
const items = getCategoryItems(category);
const completed = items.filter(item => item.completed).length;
return { completed, total: items.length, percentage: (completed / items.length) * 100 };
};
const categories = [
{
id: 'pre-market',
label: 'Pre-Market',
icon: Calendar,
color: 'text-blue-500',
description: 'Before trading begins'
},
{
id: 'active-trading',
label: 'Active Trading',
icon: TrendingUp,
color: 'text-green-500',
description: 'During market hours'
},
{
id: 'post-market',
label: 'Post-Market',
icon: BarChart3,
color: 'text-purple-500',
description: 'After trading closes'
},
];
const totalProgress = getCategoryProgress('pre-market').completed +
getCategoryProgress('active-trading').completed +
getCategoryProgress('post-market').completed;
const totalItems = checklist.length;
const overallPercentage = (totalProgress / totalItems) * 100;
const currentCategoryData = categories.find(c => c.id === activeCategory)!;
const currentProgress = getCategoryProgress(activeCategory);
const displayItems = getCategoryItems(activeCategory);
const visibleItems = showCompleted ? displayItems : displayItems.filter(item => !item.completed);
return (
<div className="h-full flex flex-col">
{/* Header with overall progress */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<Clock className="w-6 h-6 text-gold-500" />
<div>
<h3 className="text-lg font-semibold">Daily Trading Checklist</h3>
<p className="text-xs text-gray-400">
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
</p>
</div>
</div>
<button
onClick={handleResetAll}
className="text-xs text-gray-400 hover:text-white transition-colors"
>
Reset All
</button>
</div>
{/* Overall progress bar */}
<div className="mb-2">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-400">Overall Progress</span>
<span className="text-gold-500 font-semibold">
{totalProgress}/{totalItems} ({overallPercentage.toFixed(0)}%)
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-gold-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${overallPercentage}%` }}
/>
</div>
</div>
</div>
{/* Category tabs */}
<div className="flex gap-2 mb-4 border-b border-gray-700">
{categories.map(category => {
const progress = getCategoryProgress(category.id);
const Icon = category.icon;
return (
<button
key={category.id}
onClick={() => setActiveCategory(category.id as any)}
className={`flex-1 px-4 py-3 border-b-2 transition-colors ${
activeCategory === category.id
? 'border-gold-500 text-white'
: 'border-transparent text-gray-400 hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-2 mb-1">
<Icon className={`w-4 h-4 ${activeCategory === category.id ? category.color : ''}`} />
<span className="text-sm font-medium">{category.label}</span>
</div>
<div className="text-xs">
{progress.completed}/{progress.total}
</div>
</button>
);
})}
</div>
{/* Category info */}
<div className="mb-4 p-3 bg-dark-hover rounded-lg">
<div className="flex items-center justify-between">
<div>
<h4 className={`font-semibold ${currentCategoryData.color}`}>
{currentCategoryData.label}
</h4>
<p className="text-xs text-gray-400">{currentCategoryData.description}</p>
</div>
<div className="text-right">
<div className="text-lg font-bold text-gold-500">
{currentProgress.percentage.toFixed(0)}%
</div>
<div className="text-xs text-gray-400">
{currentProgress.completed} / {currentProgress.total}
</div>
</div>
</div>
</div>
{/* Show completed toggle */}
<div className="mb-3 flex items-center justify-end">
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={showCompleted}
onChange={(e) => setShowCompleted(e.target.checked)}
className="rounded"
/>
<span>Show completed</span>
</label>
</div>
{/* Checklist items */}
<div className="flex-1 overflow-y-auto space-y-2">
{visibleItems.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<CheckCircle2 className="w-12 h-12 mx-auto mb-3 text-green-500" />
<p>All tasks completed!</p>
<p className="text-sm">Great job on this section.</p>
</div>
) : (
visibleItems.map(item => (
<button
key={item.id}
onClick={() => handleToggle(item.id)}
className={`w-full p-4 rounded-lg border-2 transition-all text-left ${
item.completed
? 'border-green-500/30 bg-green-500/5'
: 'border-gray-700 hover:border-gray-600 bg-dark-hover'
}`}
>
<div className="flex items-start gap-3">
<div className="pt-0.5">
{item.completed ? (
<CheckCircle2 className="w-5 h-5 text-green-500" />
) : (
<Circle className="w-5 h-5 text-gray-500" />
)}
</div>
<div className="flex-1">
<h5 className={`font-medium mb-1 ${
item.completed ? 'text-gray-500 line-through' : 'text-white'
}`}>
{item.title}
</h5>
<p className={`text-sm ${
item.completed ? 'text-gray-600' : 'text-gray-400'
}`}>
{item.description}
</p>
</div>
</div>
</button>
))
)}
</div>
{/* Category completion message */}
{currentProgress.completed === currentProgress.total && currentProgress.total > 0 && (
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
<div className="flex items-center gap-2 text-green-500">
<AlertCircle className="w-5 h-5" />
<span className="text-sm font-medium">
{currentCategoryData.label} phase complete!
{activeCategory === 'pre-market' && ' Ready to trade.'}
{activeCategory === 'active-trading' && ' Well managed!'}
{activeCategory === 'post-market' && ' See you tomorrow!'}
</span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,402 @@
import { useState, useEffect } from 'react';
import { Sun, TrendingUp, TrendingDown, Calendar, AlertCircle, Newspaper, BarChart3 } from 'lucide-react';
interface MarketSummary {
date: string;
sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
priceAction: {
current: number;
open: number;
high: number;
low: number;
change: number;
changePercent: number;
};
keyLevels: {
resistance: number[];
support: number[];
pivot: number;
};
economicEvents: Array<{
time: string;
event: string;
impact: 'HIGH' | 'MEDIUM' | 'LOW';
forecast?: string;
}>;
newsSummary: {
bullishCount: number;
bearishCount: number;
topHeadlines: string[];
};
aiPrediction: {
direction: 'UP' | 'DOWN' | 'SIDEWAYS';
confidence: number;
keyFactors: string[];
};
}
interface DailyMarketSummaryProps {
currentPrice: number;
}
export default function DailyMarketSummary({ currentPrice }: DailyMarketSummaryProps) {
const [summary, setSummary] = useState<MarketSummary>({
date: new Date().toDateString(),
sentiment: 'NEUTRAL',
priceAction: {
current: currentPrice,
open: currentPrice - 5,
high: currentPrice + 10,
low: currentPrice - 12,
change: 5,
changePercent: 0.25,
},
keyLevels: {
resistance: [currentPrice + 20, currentPrice + 40, currentPrice + 60],
support: [currentPrice - 20, currentPrice - 40, currentPrice - 60],
pivot: currentPrice,
},
economicEvents: [
{ time: '08:30', event: 'US CPI Data', impact: 'HIGH', forecast: '0.3%' },
{ time: '10:00', event: 'Fed Speech', impact: 'HIGH' },
{ time: '14:00', event: 'Gold Inventory', impact: 'MEDIUM' },
],
newsSummary: {
bullishCount: 12,
bearishCount: 8,
topHeadlines: [
'Dollar weakens amid Fed rate cut expectations',
'Geopolitical tensions boost safe-haven demand',
'Central banks continue gold accumulation',
],
},
aiPrediction: {
direction: 'UP',
confidence: 72,
keyFactors: [
'Weakening dollar trend',
'Strong technical support holding',
'Bullish news sentiment',
'Upcoming high-impact economic data',
],
},
});
const [selectedView, setSelectedView] = useState<'overview' | 'levels' | 'events' | 'ai'>('overview');
// Update summary when price changes significantly
useEffect(() => {
setSummary(prev => ({
...prev,
priceAction: {
...prev.priceAction,
current: currentPrice,
change: currentPrice - prev.priceAction.open,
changePercent: ((currentPrice - prev.priceAction.open) / prev.priceAction.open) * 100,
},
}));
}, [currentPrice]);
const getSentimentColor = (sentiment: string) => {
switch (sentiment) {
case 'BULLISH': return 'text-green-500';
case 'BEARISH': return 'text-red-500';
default: return 'text-gray-400';
}
};
const getImpactColor = (impact: string) => {
switch (impact) {
case 'HIGH': return 'bg-red-500/20 text-red-500';
case 'MEDIUM': return 'bg-yellow-500/20 text-yellow-500';
case 'LOW': return 'bg-blue-500/20 text-blue-500';
default: return 'bg-gray-500/20 text-gray-500';
}
};
const views = [
{ id: 'overview', label: 'Overview', icon: Sun },
{ id: 'levels', label: 'Key Levels', icon: BarChart3 },
{ id: 'events', label: 'Events', icon: Calendar },
{ id: 'ai', label: 'AI Forecast', icon: TrendingUp },
];
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="mb-4 pb-4 border-b border-gray-700">
<div className="flex items-center gap-3 mb-3">
<Sun className="w-6 h-6 text-gold-500" />
<div>
<h3 className="text-lg font-semibold">Daily Market Brief</h3>
<p className="text-xs text-gray-400">
{new Date().toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
})}
</p>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-dark-hover rounded-lg">
<div className="text-xs text-gray-400 mb-1">Current Price</div>
<div className="text-xl font-bold text-white">
${summary.priceAction.current.toFixed(2)}
</div>
<div className={`text-sm font-medium ${
summary.priceAction.change >= 0 ? 'text-green-500' : 'text-red-500'
}`}>
{summary.priceAction.change >= 0 ? '+' : ''}
${summary.priceAction.change.toFixed(2)} ({summary.priceAction.changePercent.toFixed(2)}%)
</div>
</div>
<div className="p-3 bg-dark-hover rounded-lg">
<div className="text-xs text-gray-400 mb-1">Market Sentiment</div>
<div className={`text-xl font-bold ${getSentimentColor(summary.sentiment)}`}>
{summary.sentiment}
</div>
<div className="text-sm text-gray-400">
{summary.newsSummary.bullishCount} 🟢 / {summary.newsSummary.bearishCount} 🔴
</div>
</div>
</div>
</div>
{/* View Selector */}
<div className="flex gap-1 mb-4 p-1 bg-dark-bg rounded-lg">
{views.map(view => {
const Icon = view.icon;
return (
<button
key={view.id}
onClick={() => setSelectedView(view.id as any)}
className={`flex-1 px-3 py-2 rounded-lg transition-colors text-sm ${
selectedView === view.id
? 'bg-gold-500 text-black font-medium'
: 'text-gray-400 hover:text-white'
}`}
>
<Icon className="w-4 h-4 mx-auto mb-1" />
{view.label}
</button>
);
})}
</div>
{/* Content Area */}
<div className="flex-1 overflow-y-auto">
{/* Overview */}
{selectedView === 'overview' && (
<div className="space-y-4">
{/* Price Range */}
<div className="p-4 bg-dark-hover rounded-lg">
<h4 className="font-semibold mb-3 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-blue-500" />
Today's Range
</h4>
<div className="grid grid-cols-3 gap-3 text-sm">
<div>
<div className="text-gray-400 text-xs mb-1">Open</div>
<div className="font-semibold">${summary.priceAction.open.toFixed(2)}</div>
</div>
<div>
<div className="text-gray-400 text-xs mb-1">High</div>
<div className="font-semibold text-green-500">${summary.priceAction.high.toFixed(2)}</div>
</div>
<div>
<div className="text-gray-400 text-xs mb-1">Low</div>
<div className="font-semibold text-red-500">${summary.priceAction.low.toFixed(2)}</div>
</div>
</div>
</div>
{/* Top Headlines */}
<div className="p-4 bg-dark-hover rounded-lg">
<h4 className="font-semibold mb-3 flex items-center gap-2">
<Newspaper className="w-4 h-4 text-blue-500" />
Top Headlines
</h4>
<div className="space-y-2">
{summary.newsSummary.topHeadlines.map((headline, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<div className="w-1.5 h-1.5 rounded-full bg-gold-500 mt-1.5 flex-shrink-0" />
<div className="text-gray-300">{headline}</div>
</div>
))}
</div>
</div>
{/* Quick AI Insight */}
<div className={`p-4 rounded-lg border-2 ${
summary.aiPrediction.direction === 'UP'
? 'bg-green-500/10 border-green-500/30'
: summary.aiPrediction.direction === 'DOWN'
? 'bg-red-500/10 border-red-500/30'
: 'bg-gray-500/10 border-gray-500/30'
}`}>
<div className="flex items-center gap-2 mb-2">
{summary.aiPrediction.direction === 'UP' ? (
<TrendingUp className="w-5 h-5 text-green-500" />
) : (
<TrendingDown className="w-5 h-5 text-red-500" />
)}
<div>
<div className="font-semibold">AI Prediction: {summary.aiPrediction.direction}</div>
<div className="text-xs text-gray-400">
Confidence: {summary.aiPrediction.confidence}%
</div>
</div>
</div>
</div>
</div>
)}
{/* Key Levels */}
{selectedView === 'levels' && (
<div className="space-y-4">
<div className="p-4 bg-dark-hover rounded-lg">
<h4 className="font-semibold mb-3 text-green-500">Resistance Levels</h4>
<div className="space-y-2">
{summary.keyLevels.resistance.map((level, i) => (
<div key={i} className="flex items-center justify-between p-2 bg-green-500/5 rounded border border-green-500/20">
<span className="text-sm text-gray-400">R{i + 1}</span>
<span className="font-semibold">${level.toFixed(2)}</span>
<span className="text-xs text-gray-500">
+{((level - currentPrice) / currentPrice * 100).toFixed(2)}%
</span>
</div>
))}
</div>
</div>
<div className="p-4 bg-dark-hover rounded-lg border-2 border-gold-500/30">
<h4 className="font-semibold mb-2 text-gold-500">Pivot Point</h4>
<div className="text-center">
<div className="text-2xl font-bold">${summary.keyLevels.pivot.toFixed(2)}</div>
<div className="text-xs text-gray-400">Key decision level</div>
</div>
</div>
<div className="p-4 bg-dark-hover rounded-lg">
<h4 className="font-semibold mb-3 text-red-500">Support Levels</h4>
<div className="space-y-2">
{summary.keyLevels.support.map((level, i) => (
<div key={i} className="flex items-center justify-between p-2 bg-red-500/5 rounded border border-red-500/20">
<span className="text-sm text-gray-400">S{i + 1}</span>
<span className="font-semibold">${level.toFixed(2)}</span>
<span className="text-xs text-gray-500">
{((level - currentPrice) / currentPrice * 100).toFixed(2)}%
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Economic Events */}
{selectedView === 'events' && (
<div className="space-y-3">
<div className="flex items-center gap-2 mb-2">
<Calendar className="w-5 h-5 text-blue-500" />
<h4 className="font-semibold">Today's Economic Calendar</h4>
</div>
{summary.economicEvents.map((event, i) => (
<div key={i} className="p-4 bg-dark-hover rounded-lg border border-gray-700">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-12 h-12 rounded-lg bg-blue-500/20 flex items-center justify-center">
<span className="text-sm font-bold text-blue-500">{event.time}</span>
</div>
<div>
<div className="font-semibold">{event.event}</div>
{event.forecast && (
<div className="text-xs text-gray-400">Forecast: {event.forecast}</div>
)}
</div>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${getImpactColor(event.impact)}`}>
{event.impact}
</span>
</div>
</div>
))}
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg mt-4">
<div className="flex items-center gap-2 text-yellow-500 text-sm">
<AlertCircle className="w-4 h-4" />
<span>Be cautious during high-impact events</span>
</div>
</div>
</div>
)}
{/* AI Forecast */}
{selectedView === 'ai' && (
<div className="space-y-4">
<div className="p-4 bg-gradient-to-br from-purple-500/20 to-blue-500/20 rounded-lg border border-purple-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-12 h-12 rounded-lg bg-purple-500/30 flex items-center justify-center">
{summary.aiPrediction.direction === 'UP' ? (
<TrendingUp className="w-6 h-6 text-green-500" />
) : (
<TrendingDown className="w-6 h-6 text-red-500" />
)}
</div>
<div>
<div className="text-xl font-bold">
{summary.aiPrediction.direction}WARD Bias
</div>
<div className="text-sm text-gray-400">
AI Confidence: {summary.aiPrediction.confidence}%
</div>
</div>
</div>
{/* Confidence Bar */}
<div className="mb-4">
<div className="w-full bg-gray-700 rounded-full h-3">
<div
className={`h-3 rounded-full transition-all ${
summary.aiPrediction.confidence > 70
? 'bg-green-500'
: summary.aiPrediction.confidence > 50
? 'bg-yellow-500'
: 'bg-red-500'
}`}
style={{ width: `${summary.aiPrediction.confidence}%` }}
/>
</div>
</div>
</div>
{/* Key Factors */}
<div className="p-4 bg-dark-hover rounded-lg">
<h4 className="font-semibold mb-3">Key Contributing Factors</h4>
<div className="space-y-2">
{summary.aiPrediction.keyFactors.map((factor, i) => (
<div key={i} className="flex items-start gap-2">
<div className="w-6 h-6 rounded-full bg-gold-500/20 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-xs font-bold text-gold-500">{i + 1}</span>
</div>
<div className="text-sm text-gray-300">{factor}</div>
</div>
))}
</div>
</div>
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<div className="text-xs text-blue-400">
<strong>Disclaimer:</strong> AI predictions are based on historical patterns and current data. Always use proper risk management and make your own trading decisions.
</div>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,485 @@
import { useState, useEffect } from 'react';
import { Target, DollarSign, TrendingUp, TrendingDown, AlertTriangle, Save, Edit2 } from 'lucide-react';
interface TradingPlan {
date: string;
bias: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
dailyTarget: number;
maxLoss: number;
entryZone: { min: number; max: number };
targetPrice: number;
stopLoss: number;
keyLevels: {
support: number[];
resistance: number[];
};
tradingNotes: string;
maxTrades: number;
actualTrades: number;
actualPnL: number;
planFollowed: boolean;
}
interface DailyTradingPlanProps {
currentPrice: number;
onPlanUpdate?: (plan: TradingPlan) => void;
}
export default function DailyTradingPlan({ currentPrice, onPlanUpdate }: DailyTradingPlanProps) {
const [isEditing, setIsEditing] = useState(false);
const [plan, setPlan] = useState<TradingPlan>(() => {
const stored = localStorage.getItem('daily-trading-plan');
const today = new Date().toDateString();
if (stored) {
try {
const parsed = JSON.parse(stored);
// If plan is from today, use it; otherwise create new
if (parsed.date === today) {
return parsed;
}
} catch {
// Fall through to create new plan
}
}
// Create new plan for today
return {
date: today,
bias: 'NEUTRAL',
dailyTarget: 500,
maxLoss: 250,
entryZone: { min: currentPrice - 10, max: currentPrice + 10 },
targetPrice: currentPrice + 20,
stopLoss: currentPrice - 15,
keyLevels: {
support: [currentPrice - 20, currentPrice - 40],
resistance: [currentPrice + 20, currentPrice + 40],
},
tradingNotes: '',
maxTrades: 3,
actualTrades: 0,
actualPnL: 0,
planFollowed: true,
};
});
useEffect(() => {
localStorage.setItem('daily-trading-plan', JSON.stringify(plan));
if (onPlanUpdate) {
onPlanUpdate(plan);
}
}, [plan, onPlanUpdate]);
const handleSave = () => {
setIsEditing(false);
// Trigger save notification could go here
};
const handleReset = () => {
if (confirm('Create a new plan for today? This will clear current plan.')) {
const today = new Date().toDateString();
setPlan({
date: today,
bias: 'NEUTRAL',
dailyTarget: 500,
maxLoss: 250,
entryZone: { min: currentPrice - 10, max: currentPrice + 10 },
targetPrice: currentPrice + 20,
stopLoss: currentPrice - 15,
keyLevels: {
support: [currentPrice - 20, currentPrice - 40],
resistance: [currentPrice + 20, currentPrice + 40],
},
tradingNotes: '',
maxTrades: 3,
actualTrades: 0,
actualPnL: 0,
planFollowed: true,
});
setIsEditing(true);
}
};
const addSupport = () => {
setPlan(prev => ({
...prev,
keyLevels: {
...prev.keyLevels,
support: [...prev.keyLevels.support, currentPrice - 10],
},
}));
};
const addResistance = () => {
setPlan(prev => ({
...prev,
keyLevels: {
...prev.keyLevels,
resistance: [...prev.keyLevels.resistance, currentPrice + 10],
},
}));
};
const removeSupport = (index: number) => {
setPlan(prev => ({
...prev,
keyLevels: {
...prev.keyLevels,
support: prev.keyLevels.support.filter((_, i) => i !== index),
},
}));
};
const removeResistance = (index: number) => {
setPlan(prev => ({
...prev,
keyLevels: {
...prev.keyLevels,
resistance: prev.keyLevels.resistance.filter((_, i) => i !== index),
},
}));
};
const updateSupport = (index: number, value: number) => {
setPlan(prev => ({
...prev,
keyLevels: {
...prev.keyLevels,
support: prev.keyLevels.support.map((s, i) => i === index ? value : s),
},
}));
};
const updateResistance = (index: number, value: number) => {
setPlan(prev => ({
...prev,
keyLevels: {
...prev.keyLevels,
resistance: prev.keyLevels.resistance.map((r, i) => i === index ? value : r),
},
}));
};
const targetReached = plan.actualPnL >= plan.dailyTarget;
const maxLossReached = plan.actualPnL <= -plan.maxLoss;
const shouldStopTrading = targetReached || maxLossReached;
return (
<div className="h-full flex flex-col overflow-y-auto">
{/* Header */}
<div className="mb-4 pb-4 border-b border-gray-700">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<Target className="w-6 h-6 text-gold-500" />
<div>
<h3 className="text-lg font-semibold">Daily Trading Plan</h3>
<p className="text-xs text-gray-400">
{new Date(plan.date).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric'
})}
</p>
</div>
</div>
<div className="flex gap-2">
{!isEditing ? (
<>
<button
onClick={() => setIsEditing(true)}
className="btn-secondary text-sm flex items-center gap-2"
>
<Edit2 className="w-4 h-4" />
Edit
</button>
<button
onClick={handleReset}
className="btn-secondary text-sm"
>
New Plan
</button>
</>
) : (
<button
onClick={handleSave}
className="btn-primary text-sm flex items-center gap-2"
>
<Save className="w-4 h-4" />
Save
</button>
)}
</div>
</div>
</div>
{/* Market Bias */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Market Bias</label>
<div className="grid grid-cols-3 gap-2">
{(['BULLISH', 'NEUTRAL', 'BEARISH'] as const).map(bias => (
<button
key={bias}
disabled={!isEditing}
onClick={() => setPlan({ ...plan, bias })}
className={`p-3 rounded-lg border-2 transition-all ${
plan.bias === bias
? bias === 'BULLISH'
? 'border-green-500 bg-green-500/10 text-green-500'
: bias === 'BEARISH'
? 'border-red-500 bg-red-500/10 text-red-500'
: 'border-gray-500 bg-gray-500/10 text-gray-300'
: 'border-gray-700 text-gray-400 hover:border-gray-600'
} ${!isEditing ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
>
{bias === 'BULLISH' && <TrendingUp className="w-5 h-5 mx-auto mb-1" />}
{bias === 'NEUTRAL' && <div className="w-5 h-0.5 bg-current mx-auto mb-2" />}
{bias === 'BEARISH' && <TrendingDown className="w-5 h-5 mx-auto mb-1" />}
<div className="text-xs font-medium">{bias}</div>
</button>
))}
</div>
</div>
{/* Risk Parameters */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium mb-2">
<DollarSign className="w-4 h-4 inline mr-1" />
Daily Target
</label>
<input
type="number"
disabled={!isEditing}
value={plan.dailyTarget}
onChange={(e) => setPlan({ ...plan, dailyTarget: Number(e.target.value) })}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg disabled:opacity-60"
placeholder="500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
<AlertTriangle className="w-4 h-4 inline mr-1" />
Max Loss
</label>
<input
type="number"
disabled={!isEditing}
value={plan.maxLoss}
onChange={(e) => setPlan({ ...plan, maxLoss: Number(e.target.value) })}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg disabled:opacity-60"
placeholder="250"
/>
</div>
</div>
{/* Entry Zone */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Entry Zone</label>
<div className="grid grid-cols-2 gap-2">
<div>
<input
type="number"
disabled={!isEditing}
step="0.01"
value={plan.entryZone.min}
onChange={(e) =>
setPlan({
...plan,
entryZone: { ...plan.entryZone, min: Number(e.target.value) },
})
}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg text-sm disabled:opacity-60"
placeholder="Min"
/>
<p className="text-xs text-gray-500 mt-1">Minimum entry</p>
</div>
<div>
<input
type="number"
disabled={!isEditing}
step="0.01"
value={plan.entryZone.max}
onChange={(e) =>
setPlan({
...plan,
entryZone: { ...plan.entryZone, max: Number(e.target.value) },
})
}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg text-sm disabled:opacity-60"
placeholder="Max"
/>
<p className="text-xs text-gray-500 mt-1">Maximum entry</p>
</div>
</div>
</div>
{/* Target & Stop Loss */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium mb-2 text-green-500">Target Price</label>
<input
type="number"
disabled={!isEditing}
step="0.01"
value={plan.targetPrice}
onChange={(e) => setPlan({ ...plan, targetPrice: Number(e.target.value) })}
className="w-full px-3 py-2 bg-dark-bg border border-green-500/30 rounded-lg disabled:opacity-60"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-red-500">Stop Loss</label>
<input
type="number"
disabled={!isEditing}
step="0.01"
value={plan.stopLoss}
onChange={(e) => setPlan({ ...plan, stopLoss: Number(e.target.value) })}
className="w-full px-3 py-2 bg-dark-bg border border-red-500/30 rounded-lg disabled:opacity-60"
/>
</div>
</div>
{/* Key Levels */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Support Levels</label>
<div className="space-y-2 mb-2">
{plan.keyLevels.support.map((level, index) => (
<div key={index} className="flex gap-2">
<input
type="number"
disabled={!isEditing}
step="0.01"
value={level}
onChange={(e) => updateSupport(index, Number(e.target.value))}
className="flex-1 px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg text-sm disabled:opacity-60"
/>
{isEditing && (
<button
onClick={() => removeSupport(index)}
className="px-3 py-2 bg-red-500/20 text-red-500 rounded-lg hover:bg-red-500/30"
>
</button>
)}
</div>
))}
</div>
{isEditing && (
<button onClick={addSupport} className="btn-secondary text-sm w-full">
+ Add Support
</button>
)}
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Resistance Levels</label>
<div className="space-y-2 mb-2">
{plan.keyLevels.resistance.map((level, index) => (
<div key={index} className="flex gap-2">
<input
type="number"
disabled={!isEditing}
step="0.01"
value={level}
onChange={(e) => updateResistance(index, Number(e.target.value))}
className="flex-1 px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg text-sm disabled:opacity-60"
/>
{isEditing && (
<button
onClick={() => removeResistance(index)}
className="px-3 py-2 bg-red-500/20 text-red-500 rounded-lg hover:bg-red-500/30"
>
</button>
)}
</div>
))}
</div>
{isEditing && (
<button onClick={addResistance} className="btn-secondary text-sm w-full">
+ Add Resistance
</button>
)}
</div>
{/* Max Trades */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Maximum Trades Today</label>
<input
type="number"
disabled={!isEditing}
min={1}
max={10}
value={plan.maxTrades}
onChange={(e) => setPlan({ ...plan, maxTrades: Number(e.target.value) })}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg disabled:opacity-60"
/>
</div>
{/* Trading Notes */}
<div className="mb-4">
<label className="block text-sm font-medium mb-2">Trading Notes & Strategy</label>
<textarea
disabled={!isEditing}
value={plan.tradingNotes}
onChange={(e) => setPlan({ ...plan, tradingNotes: e.target.value })}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded-lg disabled:opacity-60"
rows={4}
placeholder="Market conditions, strategy notes, things to watch..."
/>
</div>
{/* Actual Performance (read-only summary) */}
<div className="mt-auto pt-4 border-t border-gray-700">
<h4 className="text-sm font-semibold mb-3">Today's Performance</h4>
<div className="grid grid-cols-2 gap-3 mb-3">
<div className="p-3 bg-dark-hover rounded-lg">
<div className="text-xs text-gray-400 mb-1">Actual P&L</div>
<div className={`text-lg font-bold ${
plan.actualPnL > 0 ? 'text-green-500' : plan.actualPnL < 0 ? 'text-red-500' : 'text-gray-400'
}`}>
${plan.actualPnL.toFixed(2)}
</div>
</div>
<div className="p-3 bg-dark-hover rounded-lg">
<div className="text-xs text-gray-400 mb-1">Trades Taken</div>
<div className={`text-lg font-bold ${
plan.actualTrades >= plan.maxTrades ? 'text-red-500' : 'text-white'
}`}>
{plan.actualTrades} / {plan.maxTrades}
</div>
</div>
</div>
{/* Trading Status Alert */}
{shouldStopTrading && (
<div className={`p-3 rounded-lg border-2 ${
targetReached
? 'bg-green-500/10 border-green-500/30 text-green-500'
: 'bg-red-500/10 border-red-500/30 text-red-500'
}`}>
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="w-5 h-5" />
{targetReached ? 'Daily Target Reached!' : 'Max Loss Hit!'}
</div>
<p className="text-sm mt-1">
{targetReached
? 'Consider stopping trading for today.'
: 'Stop trading immediately to preserve capital.'}
</p>
</div>
)}
{plan.actualTrades >= plan.maxTrades && !shouldStopTrading && (
<div className="p-3 bg-yellow-500/10 border-2 border-yellow-500/30 rounded-lg text-yellow-500">
<div className="flex items-center gap-2 font-semibold text-sm">
<AlertTriangle className="w-4 h-4" />
Maximum trades reached
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,401 @@
import { useState } from 'react';
import { Settings, Layout, Grid, Columns, Save, RotateCcw, Eye, EyeOff, GripVertical } from 'lucide-react';
import type { DashboardConfig, TabConfig, LayoutMode, LayoutPreset } from '@/types';
interface DashboardCustomizerProps {
config: DashboardConfig;
onConfigChange: (config: DashboardConfig) => void;
onSavePreset: (name: string, description: string) => void;
onLoadPreset: (presetId: string) => void;
onResetToDefault: () => void;
}
const DEFAULT_PRESETS: LayoutPreset[] = [
{
id: 'trading-focus',
name: 'Trading Focus',
description: 'Optimized for active trading with chart and controls prominent',
mode: 'grid',
tabs: [],
},
{
id: 'analysis-focus',
name: 'Analysis Focus',
description: 'Full-screen analytics and AI insights',
mode: 'tabs',
tabs: [],
},
{
id: 'news-focus',
name: 'News Focus',
description: 'News and market updates at the forefront',
mode: 'split',
tabs: [],
},
{
id: 'balanced',
name: 'Balanced View',
description: 'Equal emphasis on all components',
mode: 'grid',
tabs: [],
},
];
export default function DashboardCustomizer({
config,
onConfigChange,
onSavePreset,
onLoadPreset,
onResetToDefault,
}: DashboardCustomizerProps) {
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<'layout' | 'tabs' | 'presets'>('layout');
const [draggedTab, setDraggedTab] = useState<TabConfig | null>(null);
const [presetName, setPresetName] = useState('');
const [presetDescription, setPresetDescription] = useState('');
const handleLayoutModeChange = (mode: LayoutMode) => {
onConfigChange({ ...config, mode });
};
const handleTabToggle = (tabId: string) => {
const updatedTabs = config.tabs.map((tab) =>
tab.id === tabId ? { ...tab, visible: !tab.visible } : tab
);
onConfigChange({ ...config, tabs: updatedTabs });
};
const handleTabPin = (tabId: string) => {
const updatedTabs = config.tabs.map((tab) =>
tab.id === tabId ? { ...tab, pinned: !tab.pinned } : tab
);
onConfigChange({ ...config, tabs: updatedTabs });
};
const handleTabSizeChange = (tabId: string, size: TabConfig['size']) => {
const updatedTabs = config.tabs.map((tab) =>
tab.id === tabId ? { ...tab, size } : tab
);
onConfigChange({ ...config, tabs: updatedTabs });
};
const handleDragStart = (tab: TabConfig) => {
setDraggedTab(tab);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDrop = (targetTab: TabConfig) => {
if (!draggedTab) return;
const updatedTabs = [...config.tabs];
const draggedIndex = updatedTabs.findIndex((t) => t.id === draggedTab.id);
const targetIndex = updatedTabs.findIndex((t) => t.id === targetTab.id);
if (draggedIndex !== -1 && targetIndex !== -1) {
updatedTabs.splice(draggedIndex, 1);
updatedTabs.splice(targetIndex, 0, draggedTab);
// Update order numbers
const reorderedTabs = updatedTabs.map((tab, index) => ({
...tab,
order: index,
}));
onConfigChange({ ...config, tabs: reorderedTabs });
}
setDraggedTab(null);
};
const handleSavePreset = () => {
if (presetName.trim()) {
onSavePreset(presetName, presetDescription);
setPresetName('');
setPresetDescription('');
setActiveTab('presets');
}
};
const allPresets = [...DEFAULT_PRESETS, ...config.customPresets];
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="btn-secondary flex items-center gap-2"
title="Customize Dashboard"
>
<Settings className="w-4 h-4" />
<span>Customize</span>
</button>
);
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-dark-card rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-6 border-b border-gray-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Settings className="w-6 h-6 text-gold-500" />
<div>
<h2 className="text-xl font-bold">Dashboard Customization</h2>
<p className="text-sm text-gray-400">Personalize your trading interface</p>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="text-gray-400 hover:text-white transition-colors"
>
</button>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-700">
<button
onClick={() => setActiveTab('layout')}
className={`px-6 py-3 font-medium transition-colors ${
activeTab === 'layout'
? 'text-gold-500 border-b-2 border-gold-500'
: 'text-gray-400 hover:text-white'
}`}
>
<Layout className="w-4 h-4 inline mr-2" />
Layout Mode
</button>
<button
onClick={() => setActiveTab('tabs')}
className={`px-6 py-3 font-medium transition-colors ${
activeTab === 'tabs'
? 'text-gold-500 border-b-2 border-gold-500'
: 'text-gray-400 hover:text-white'
}`}
>
<Columns className="w-4 h-4 inline mr-2" />
Tabs & Order
</button>
<button
onClick={() => setActiveTab('presets')}
className={`px-6 py-3 font-medium transition-colors ${
activeTab === 'presets'
? 'text-gold-500 border-b-2 border-gold-500'
: 'text-gray-400 hover:text-white'
}`}
>
<Grid className="w-4 h-4 inline mr-2" />
Presets
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{activeTab === 'layout' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Choose Layout Mode</h3>
<div className="grid grid-cols-3 gap-4">
<button
onClick={() => handleLayoutModeChange('grid')}
className={`p-4 rounded-lg border-2 transition-all ${
config.mode === 'grid'
? 'border-gold-500 bg-gold-500/10'
: 'border-gray-700 hover:border-gray-600'
}`}
>
<Grid className="w-8 h-8 mx-auto mb-2" />
<div className="font-medium">Grid</div>
<div className="text-xs text-gray-400 mt-1">
Multiple panels visible
</div>
</button>
<button
onClick={() => handleLayoutModeChange('tabs')}
className={`p-4 rounded-lg border-2 transition-all ${
config.mode === 'tabs'
? 'border-gold-500 bg-gold-500/10'
: 'border-gray-700 hover:border-gray-600'
}`}
>
<Columns className="w-8 h-8 mx-auto mb-2" />
<div className="font-medium">Tabs</div>
<div className="text-xs text-gray-400 mt-1">
One panel at a time
</div>
</button>
<button
onClick={() => handleLayoutModeChange('split')}
className={`p-4 rounded-lg border-2 transition-all ${
config.mode === 'split'
? 'border-gold-500 bg-gold-500/10'
: 'border-gray-700 hover:border-gray-600'
}`}
>
<Layout className="w-8 h-8 mx-auto mb-2" />
<div className="font-medium">Split</div>
<div className="text-xs text-gray-400 mt-1">
Two main sections
</div>
</button>
</div>
</div>
</div>
)}
{activeTab === 'tabs' && (
<div className="space-y-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Manage Tabs</h3>
<p className="text-sm text-gray-400">Drag to reorder, toggle visibility</p>
</div>
<div className="space-y-2">
{config.tabs
.sort((a, b) => a.order - b.order)
.map((tab) => (
<div
key={tab.id}
draggable
onDragStart={() => handleDragStart(tab)}
onDragOver={handleDragOver}
onDrop={() => handleDrop(tab)}
className={`p-4 rounded-lg border-2 transition-all cursor-move ${
tab.visible
? 'border-gray-700 bg-dark-hover'
: 'border-gray-800 bg-gray-900/50 opacity-60'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<GripVertical className="w-5 h-5 text-gray-500" />
<div>
<div className="font-medium">{tab.label}</div>
<div className="text-xs text-gray-400">Order: {tab.order + 1}</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Size selector */}
<select
value={tab.size}
onChange={(e) =>
handleTabSizeChange(tab.id, e.target.value as TabConfig['size'])
}
className="px-2 py-1 bg-dark-bg border border-gray-700 rounded text-sm"
disabled={!tab.visible}
>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
<option value="full">Full</option>
</select>
{/* Pin button */}
<button
onClick={() => handleTabPin(tab.id)}
className={`p-2 rounded ${
tab.pinned ? 'text-gold-500' : 'text-gray-400 hover:text-white'
}`}
title="Pin tab"
disabled={!tab.visible}
>
📌
</button>
{/* Visibility toggle */}
<button
onClick={() => handleTabToggle(tab.id)}
className="p-2 rounded hover:bg-dark-hover"
title={tab.visible ? 'Hide' : 'Show'}
>
{tab.visible ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{activeTab === 'presets' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4">Quick Presets</h3>
<div className="grid grid-cols-2 gap-4 mb-6">
{allPresets.map((preset) => (
<button
key={preset.id}
onClick={() => onLoadPreset(preset.id)}
className={`p-4 rounded-lg border-2 text-left transition-all ${
config.activePreset === preset.id
? 'border-gold-500 bg-gold-500/10'
: 'border-gray-700 hover:border-gray-600'
}`}
>
<div className="font-medium mb-1">{preset.name}</div>
<div className="text-xs text-gray-400">{preset.description}</div>
</button>
))}
</div>
</div>
<div className="border-t border-gray-700 pt-6">
<h3 className="text-lg font-semibold mb-4">Save Current Layout</h3>
<div className="space-y-3">
<input
type="text"
placeholder="Preset name..."
value={presetName}
onChange={(e) => setPresetName(e.target.value)}
className="w-full px-4 py-2 bg-dark-bg border border-gray-700 rounded-lg focus:border-gold-500 focus:outline-none"
/>
<textarea
placeholder="Description (optional)..."
value={presetDescription}
onChange={(e) => setPresetDescription(e.target.value)}
className="w-full px-4 py-2 bg-dark-bg border border-gray-700 rounded-lg focus:border-gold-500 focus:outline-none"
rows={2}
/>
<button
onClick={handleSavePreset}
disabled={!presetName.trim()}
className="btn-primary w-full flex items-center justify-center gap-2"
>
<Save className="w-4 h-4" />
Save as New Preset
</button>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-gray-700 flex items-center justify-between">
<button
onClick={onResetToDefault}
className="btn-secondary flex items-center gap-2 text-red-500 hover:text-red-400"
>
<RotateCcw className="w-4 h-4" />
Reset to Default
</button>
<button onClick={() => setIsOpen(false)} className="btn-primary">
Done
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react'
import { decisionsApi } from '@/services/api'
export default function DecisionLogPanel() {
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let mounted = true
;(async () => {
try {
setLoading(true)
const rows = await decisionsApi.getLatest(50)
if (mounted) setItems(rows)
} catch (e: any) {
setError(e?.message || 'Failed to load decisions')
} finally {
setLoading(false)
}
})()
return () => { mounted = false }
}, [])
return (
<div className="card">
<div className="title">Decision Log</div>
{loading && <div className="text-gray-400">Loading</div>}
{error && <div className="text-red-500">{error}</div>}
{!loading && !error && (
<div style={{ overflowX: 'auto' }}>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-400">
<th>Time</th>
<th>Symbol</th>
<th>TF</th>
<th>Rec</th>
<th>Conf</th>
<th>Risk</th>
<th>Rationale</th>
</tr>
</thead>
<tbody>
{items.map((d) => (
<tr key={d.id} className="border-t border-dark-border">
<td className="whitespace-nowrap">{new Date(d.time).toLocaleString()}</td>
<td>{d.symbol}</td>
<td>{d.timeframe}</td>
<td>{d.recommendation}</td>
<td>{(Number(d.confidence) * 100).toFixed(0)}%</td>
<td>{d.risk_level}</td>
<td className="max-w-[600px] truncate" title={d.rationale}>{d.rationale}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { performanceApi } from '@/services/api'
import SimpleLineChart from './SimpleLineChart'
export default function EquityPerformancePanel() {
const [equity, setEquity] = useState<{ time: number; equity: number }[]>([])
const [perf, setPerf] = useState<any | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let mounted = true
;(async () => {
try {
setLoading(true)
const [eq, pf] = await Promise.all([
performanceApi.getEquityHistory('XAU/USD', '1m', 300),
performanceApi.getPerformance('XAU/USD', '1m', 300),
])
if (mounted) { setEquity(eq); setPerf(pf) }
} catch (e: any) {
setError(e?.message || 'Failed to load equity/performance')
} finally {
setLoading(false)
}
})()
return () => { mounted = false }
}, [])
return (
<div className="card">
<div className="title">Equity & Performance</div>
{loading && <div className="text-gray-400">Loading</div>}
{error && <div className="text-red-500">{error}</div>}
{!loading && !error && (
<div style={{ display: 'grid', gap: 12 }}>
<SimpleLineChart title="Equity Curve" data={equity.map(r => ({ time: r.time, value: r.equity }))} />
{perf?.available ? (
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Metric label="Total Return" value={pct(perf.total_return)} />
<Metric label="Sharpe" value={num(perf.sharpe)} />
<Metric label="Sortino" value={num(perf.sortino)} />
<Metric label="Max Drawdown" value={pct(perf.max_drawdown)} />
</div>
) : (
<div className="text-gray-400">Not enough data</div>
)}
</div>
)}
</div>
)
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<div className="card" style={{ padding: 12 }}>
<div className="text-gray-400 text-xs">{label}</div>
<div className="text-gray-100 text-lg font-semibold">{value}</div>
</div>
)
}
function pct(v?: number | null) {
if (v === null || v === undefined) return '-'
return (v * 100).toFixed(2) + '%'
}
function num(v?: number | null) {
if (v === null || v === undefined) return '-'
return String(Number(v).toFixed(2))
}
+78
View File
@@ -0,0 +1,78 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({ errorInfo });
}
handleReset = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="card bg-red-500/10 border-red-500/50">
<div className="flex items-start gap-3">
<AlertTriangle className="w-6 h-6 text-red-500 flex-shrink-0 mt-1" />
<div className="flex-1">
<h3 className="text-lg font-semibold text-red-500 mb-2">
Something went wrong
</h3>
<p className="text-sm text-gray-400 mb-4">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
{process.env.NODE_ENV === 'development' && this.state.errorInfo && (
<details className="mb-4">
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-400">
Error Details
</summary>
<pre className="mt-2 p-2 bg-dark-bg rounded text-xs text-gray-500 overflow-auto max-h-40">
{this.state.error?.stack}
{'\n\n'}
{this.state.errorInfo.componentStack}
</pre>
</details>
)}
<button
onClick={this.handleReset}
className="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-700 rounded-md text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" />
Try Again
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
+83
View File
@@ -0,0 +1,83 @@
import { useState } from 'react';
import { Download, FileText, Table, BarChart } from 'lucide-react';
import { exportTradesToCSV, exportPortfolioSummary, exportAnalyticsReport } from '@/utils/export';
import type { Portfolio } from '@/types';
interface ExportMenuProps {
portfolio: Portfolio;
analytics: any;
}
export default function ExportMenu({ portfolio, analytics }: ExportMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const handleExportCSV = () => {
exportTradesToCSV(portfolio.trades, portfolio);
setIsOpen(false);
};
const handleExportJSON = () => {
exportPortfolioSummary(portfolio);
setIsOpen(false);
};
const handleExportReport = () => {
exportAnalyticsReport(portfolio, analytics);
setIsOpen(false);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 bg-dark-surface rounded-md hover:bg-dark-hover transition-colors"
>
<Download className="w-4 h-4" />
<span className="text-sm">Export</span>
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div className="absolute top-full mt-2 right-0 w-64 bg-dark-surface border border-dark-border rounded-lg shadow-xl z-50">
<div className="p-2">
<button
onClick={handleExportCSV}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-dark-hover rounded transition-colors text-left"
>
<Table className="w-4 h-4 text-green-500" />
<div>
<p className="text-sm font-medium">Export to CSV</p>
<p className="text-xs text-gray-400">All trades data</p>
</div>
</button>
<button
onClick={handleExportJSON}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-dark-hover rounded transition-colors text-left mt-1"
>
<FileText className="w-4 h-4 text-blue-500" />
<div>
<p className="text-sm font-medium">Export to JSON</p>
<p className="text-xs text-gray-400">Portfolio summary</p>
</div>
</button>
<button
onClick={handleExportReport}
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-dark-hover rounded transition-colors text-left mt-1"
>
<BarChart className="w-4 h-4 text-purple-500" />
<div>
<p className="text-sm font-medium">Analytics Report</p>
<p className="text-xs text-gray-400">Full text report</p>
</div>
</button>
</div>
</div>
</>
)}
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from 'react';
import { createChart, IChartApi, ISeriesApi, CandlestickData } from 'lightweight-charts';
import type { PriceData } from '@/types';
interface GoldChartProps {
data: PriceData[];
smaData?: { time: number; value: number }[];
onCrosshairMove?: (price: number | null) => void;
liveUpdate?: PriceData | null;
}
export default function GoldChart({ data, smaData, onCrosshairMove, liveUpdate }: GoldChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const candleSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
const smaSeriesRef = useRef<ISeriesApi<'Line'> | null>(null);
const [currentPrice, setCurrentPrice] = useState<number | null>(null);
useEffect(() => {
if (!chartContainerRef.current) return;
// Create chart
const chart = createChart(chartContainerRef.current, {
layout: {
background: { color: '#1a1f2e' },
textColor: '#d1d4dc',
},
grid: {
vertLines: { color: '#2a3142' },
horzLines: { color: '#2a3142' },
},
width: chartContainerRef.current.clientWidth,
height: 500,
timeScale: {
timeVisible: true,
secondsVisible: false,
borderColor: '#2a3142',
},
rightPriceScale: {
borderColor: '#2a3142',
},
crosshair: {
mode: 1,
vertLine: {
color: '#758696',
width: 1,
style: 3,
labelBackgroundColor: '#4682B4',
},
horzLine: {
color: '#758696',
width: 1,
style: 3,
labelBackgroundColor: '#4682B4',
},
},
});
chartRef.current = chart;
// Add candlestick series
const candleSeries = chart.addCandlestickSeries({
upColor: '#10b981',
downColor: '#ef4444',
borderUpColor: '#10b981',
borderDownColor: '#ef4444',
wickUpColor: '#10b981',
wickDownColor: '#ef4444',
});
candleSeriesRef.current = candleSeries;
// Add SMA series
const smaSeries = chart.addLineSeries({
color: '#FFD700',
lineWidth: 2,
title: 'SMA',
});
smaSeriesRef.current = smaSeries;
// Handle resize
const handleResize = () => {
if (chartContainerRef.current && chartRef.current) {
chartRef.current.applyOptions({
width: chartContainerRef.current.clientWidth,
});
}
};
window.addEventListener('resize', handleResize);
// Handle crosshair move
chart.subscribeCrosshairMove((param) => {
if (param.time) {
const data = param.seriesData.get(candleSeries) as CandlestickData | undefined;
if (data) {
setCurrentPrice(data.close);
onCrosshairMove?.(data.close);
}
} else {
setCurrentPrice(null);
onCrosshairMove?.(null);
}
});
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
};
}, [onCrosshairMove]);
// Update data
useEffect(() => {
if (candleSeriesRef.current && data.length > 0) {
const formattedData = data.map((d) => ({
time: d.time as any,
open: d.open,
high: d.high,
low: d.low,
close: d.close,
}));
candleSeriesRef.current.setData(formattedData);
setCurrentPrice(data[data.length - 1].close);
}
}, [data]);
// Update SMA
useEffect(() => {
if (smaSeriesRef.current && smaData && smaData.length > 0) {
const formattedSMA = smaData.map((d) => ({
time: d.time as any,
value: d.value,
}));
smaSeriesRef.current.setData(formattedSMA);
}
}, [smaData]);
// Handle live price updates
useEffect(() => {
if (!liveUpdate || !candleSeriesRef.current || data.length === 0) return;
// Get the last candle timestamp from historical data
const lastHistoricalTime = data[data.length - 1].time;
// Only update if the new time is newer than or equal to the last historical time
// This prevents the "Cannot update oldest data" error
if (liveUpdate.time < lastHistoricalTime) {
console.log('Skipping live update: timestamp is older than historical data');
return;
}
const newCandle = {
time: liveUpdate.time as any,
open: liveUpdate.open,
high: liveUpdate.high,
low: liveUpdate.low,
close: liveUpdate.close,
};
// Update the chart with the new candle
candleSeriesRef.current.update(newCandle);
setCurrentPrice(liveUpdate.close);
}, [liveUpdate, data]);
return (
<div className="card">
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-xl font-bold text-gold-500">XAU/USD - Gold Trading</h2>
{currentPrice && (
<p className="text-sm text-gray-400">
Current: ${currentPrice.toFixed(2)}
</p>
)}
</div>
<div className="flex gap-2">
<span className="px-3 py-1 bg-dark-bg rounded-md text-sm">
<span className="text-gold-500"></span> SMA(50)
</span>
</div>
</div>
<div ref={chartContainerRef} className="w-full" />
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useState } from 'react';
import { Settings, Check, X } from 'lucide-react';
interface IndicatorConfig {
id: string;
name: string;
enabled: boolean;
color: string;
params: Record<string, number>;
}
interface IndicatorPanelProps {
indicators: IndicatorConfig[];
onChange: (indicators: IndicatorConfig[]) => void;
}
export default function IndicatorPanel({ indicators, onChange }: IndicatorPanelProps) {
const [isOpen, setIsOpen] = useState(false);
const toggleIndicator = (id: string) => {
const updated = indicators.map((ind) =>
ind.id === id ? { ...ind, enabled: !ind.enabled } : ind
);
onChange(updated);
};
const updateParam = (id: string, param: string, value: number) => {
// Validate input
if (isNaN(value) || value <= 0) {
return; // Don't update with invalid values
}
const updated = indicators.map((ind) =>
ind.id === id ? { ...ind, params: { ...ind.params, [param]: value } } : ind
);
onChange(updated);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 bg-dark-surface rounded-md hover:bg-dark-hover transition-colors"
>
<Settings className="w-4 h-4" />
<span className="text-sm">Indicators</span>
<span className="text-xs bg-blue-600 px-2 py-0.5 rounded-full">
{indicators.filter((i) => i.enabled).length}
</span>
</button>
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
{/* Panel */}
<div className="absolute top-full mt-2 right-0 w-80 bg-dark-surface border border-dark-border rounded-lg shadow-xl z-50">
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">Technical Indicators</h3>
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-dark-hover rounded"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{indicators.map((indicator) => (
<div
key={indicator.id}
className="p-3 bg-dark-bg rounded-lg border border-dark-border"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: indicator.color }}
/>
<span className="font-medium text-sm">{indicator.name}</span>
</div>
<button
onClick={() => toggleIndicator(indicator.id)}
className={`
p-1 rounded transition-colors
${
indicator.enabled
? 'bg-green-600 text-white'
: 'bg-gray-700 text-gray-400'
}
`}
>
{indicator.enabled ? (
<Check className="w-4 h-4" />
) : (
<X className="w-4 h-4" />
)}
</button>
</div>
{indicator.enabled && Object.keys(indicator.params).length > 0 && (
<div className="space-y-2 mt-3 pt-3 border-t border-dark-border">
{Object.entries(indicator.params).map(([param, value]) => (
<div key={param}>
<label className="block text-xs text-gray-400 mb-1">
{param.charAt(0).toUpperCase() + param.slice(1)}
</label>
<input
type="number"
value={value}
onChange={(e) =>
updateParam(indicator.id, param, Number(e.target.value))
}
min="1"
step="1"
className="w-full px-2 py-1 text-sm bg-dark-bg border border-dark-border rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</div>
))}
</div>
)}
</div>
))}
</div>
<div className="mt-4 pt-4 border-t border-dark-border">
<button
onClick={() => {
const allEnabled = indicators.map((ind) => ({ ...ind, enabled: true }));
onChange(allEnabled);
}}
className="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium transition-colors"
>
Enable All
</button>
<button
onClick={() => {
const allDisabled = indicators.map((ind) => ({ ...ind, enabled: false }));
onChange(allDisabled);
}}
className="w-full mt-2 px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded text-sm font-medium transition-colors"
>
Disable All
</button>
</div>
</div>
</div>
</>
)}
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useRef } from 'react'
import { createChart, IChartApi, ISeriesApi } from 'lightweight-charts'
function buildWsUrl(pathAndQuery: string) {
const api = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
const base = api.replace(/\/$/, '') // trim trailing slash
const wsBase = base.replace(/^http/, 'ws').replace(/\/$/, '')
return wsBase + pathAndQuery
}
type Bar = { time: any; open: number; high: number; low: number; close: number }
function tfToSeconds(tf: string): number {
switch (tf) {
case '1m': return 60
case '5m': return 5 * 60
case '15m': return 15 * 60
case '30m': return 30 * 60
case '1h': return 60 * 60
case '4h': return 4 * 60 * 60
default: return 60
}
}
export default function LiveKlineChart({ symbol, timeframe = '1m' }: { symbol: string; timeframe?: string }) {
const containerRef = useRef<HTMLDivElement | null>(null)
const chartRef = useRef<IChartApi | null>(null)
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const wsRef = useRef<WebSocket | null>(null)
const bucketSecondsRef = useRef<number>(tfToSeconds(timeframe))
const bucketsRef = useRef<Map<number, Bar>>(new Map())
useEffect(() => {
if (!containerRef.current) return
const chart = createChart(containerRef.current, {
layout: { background: { color: '#0f172a' }, textColor: '#e2e8f0' },
grid: { vertLines: { color: '#1f2937' }, horzLines: { color: '#1f2937' } },
rightPriceScale: { borderColor: '#1f2937' },
timeScale: { borderColor: '#1f2937', timeVisible: true, secondsVisible: false },
height: 320,
width: containerRef.current.clientWidth,
})
chartRef.current = chart
const series = chart.addCandlestickSeries({
upColor: '#16a34a', downColor: '#dc2626', borderUpColor: '#16a34a', borderDownColor: '#dc2626', wickUpColor: '#16a34a', wickDownColor: '#dc2626',
})
seriesRef.current = series
const onResize = () => {
if (!containerRef.current || !chartRef.current) return
chartRef.current.applyOptions({ width: containerRef.current.clientWidth })
}
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
chart.remove()
}
}, [])
// Keep a rolling 1m history to allow instant timeframe switches without refetch
const history1mRef = useRef<Bar[]>([])
// Load initial 1m history when symbol changes
useEffect(() => {
;(async () => {
try {
bucketsRef.current = new Map()
if (seriesRef.current) seriesRef.current.setData([])
const { marketDataApi } = await import('@/services/api')
const rows = await marketDataApi.getOHLCV(symbol, '1m', 1000)
const bars: Bar[] = rows.map(r => ({ time: r.time as any, open: r.open, high: r.high, low: r.low, close: r.close }))
history1mRef.current = bars
// Build current timeframe view from 1m history
const tfSec = tfToSeconds(timeframe)
const map = new Map<number, Bar>()
for (const r of bars) {
const t = r.time as number
const b = Math.floor(t / tfSec) * tfSec
const cur = map.get(b)
if (!cur) {
map.set(b, { time: b as any, open: r.open, high: r.high, low: r.low, close: r.close })
} else {
cur.high = Math.max(cur.high, r.high)
cur.low = Math.min(cur.low, r.low)
cur.close = r.close
}
}
bucketsRef.current = map
if (seriesRef.current) {
seriesRef.current.setData(Array.from(map.values()))
}
} catch {}
})()
}, [symbol])
// On timeframe change, rebuild view from 1m history only (no refetch)
useEffect(() => {
bucketSecondsRef.current = tfToSeconds(timeframe)
const tfSec = bucketSecondsRef.current
const map = new Map<number, Bar>()
for (const r of history1mRef.current) {
const t = r.time as number
const b = Math.floor(t / tfSec) * tfSec
const cur = map.get(b)
if (!cur) {
map.set(b, { time: b as any, open: r.open, high: r.high, low: r.low, close: r.close })
} else {
cur.high = Math.max(cur.high, r.high)
cur.low = Math.min(cur.low, r.low)
cur.close = r.close
}
}
bucketsRef.current = map
if (seriesRef.current) seriesRef.current.setData(Array.from(map.values()))
}, [timeframe])
useEffect(() => {
// Always subscribe to 1m from backend to minimize upstream loads
const path = `/stream/klines?symbols=${encodeURIComponent(symbol)}&timeframe=1m`
const url = buildWsUrl(path)
const ws = new WebSocket(url)
wsRef.current = ws
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data)
if (!msg || msg.symbol.replace('/', '') !== symbol.replace('/', '')) return
const t = Math.floor(Date.parse(msg.close_time) / 1000)
// Update rolling 1m history (append or replace last if same minute)
const last = history1mRef.current[history1mRef.current.length - 1]
if (last && (last.time as number) === t) {
// update last bar
last.high = Math.max(last.high, msg.high)
last.low = Math.min(last.low, msg.low)
last.close = msg.close
} else if (!last || (last.time as number) < t) {
history1mRef.current.push({ time: t as any, open: msg.open, high: msg.high, low: msg.low, close: msg.close })
if (history1mRef.current.length > 2000) history1mRef.current.shift()
}
// Update current timeframe bucket
const bucket = Math.floor(t / bucketSecondsRef.current) * bucketSecondsRef.current
const existing = bucketsRef.current.get(bucket)
const update: Bar = existing
? {
time: bucket as any,
open: existing.open,
high: Math.max(existing.high, msg.high),
low: Math.min(existing.low, msg.low),
close: msg.close,
}
: {
time: bucket as any,
open: msg.open,
high: msg.high,
low: msg.low,
close: msg.close,
}
bucketsRef.current.set(bucket, update)
seriesRef.current?.update(update)
} catch {}
}
ws.onerror = () => {}
return () => { try { ws.close() } catch {} }
}, [symbol])
return (
<div className="card">
<div className="title">{symbol} {timeframe}</div>
<div ref={containerRef} className="chart" />
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useMemo, useState } from 'react'
import SimpleKlineChart from './SimpleKlineChart'
import SymbolTimeframeSelector from './SymbolTimeframeSelector'
import { useSSEMultiplexer } from '@/hooks/useSSEMultiplexer'
function tfToSeconds(tf: string): number {
switch (tf) {
case '1m': return 60
case '5m': return 5 * 60
case '1h': return 60 * 60
case '4h': return 4 * 60 * 60
default: return 60
}
}
type Bar = { time: number; open: number; high: number; low: number; close: number }
function resampleFrom1m(bars1m: Bar[], timeframe: string): Bar[] {
const tfSec = tfToSeconds(timeframe)
const buckets = new Map<number, Bar>()
for (const r of bars1m) {
const b = Math.floor(r.time / tfSec) * tfSec
const cur = buckets.get(b)
if (!cur) {
buckets.set(b, { time: b, open: r.open, high: r.high, low: r.low, close: r.close })
} else {
cur.high = Math.max(cur.high, r.high)
cur.low = Math.min(cur.low, r.low)
cur.close = r.close
}
}
return Array.from(buckets.values()).sort((a, b) => a.time - b.time)
}
export default function LiveMarketPanel() {
const [symbol1, setSymbol1] = useState<string>('BTCUSDT')
const [tf1, setTf1] = useState<string>('1m')
const [symbol2, setSymbol2] = useState<string>('XAUUSD')
const [tf2, setTf2] = useState<string>('1m')
const symbols = useMemo(() => Array.from(new Set([symbol1, symbol2])), [symbol1, symbol2])
const { isConnected, lastBySymbol } = useSSEMultiplexer(symbols, '1m')
const [history1m, setHistory1m] = useState<Record<string, Bar[]>>({})
// Load initial 1m history for selected symbols
useEffect(() => {
let cancelled = false
;(async () => {
const next: Record<string, Bar[]> = { ...history1m }
for (const s of symbols) {
if (next[s]?.length) continue
try {
const rows = await (await import('@/services/api')).marketDataApi.getOHLCV(s, '1m', 1000)
next[s] = rows.map(r => ({ time: r.time as number, open: r.open, high: r.high, low: r.low, close: r.close }))
} catch {
next[s] = []
}
}
if (!cancelled) setHistory1m(next)
})()
return () => { cancelled = true }
}, [symbols.join(',')])
// Apply incoming SSE events to 1m history buffers
useEffect(() => {
if (!Object.keys(lastBySymbol).length) return
setHistory1m(prev => {
const next = { ...prev }
for (const [sym, evt] of Object.entries(lastBySymbol)) {
if (!evt) continue
const t = Math.floor(Date.parse(evt.close_time) / 1000)
const arr = next[sym] ? [...next[sym]] : []
if (arr.length && arr[arr.length - 1].time === t) {
arr[arr.length - 1] = { time: t, open: evt.open, high: evt.high, low: evt.low, close: evt.close }
} else if (!arr.length || arr[arr.length - 1].time < t) {
arr.push({ time: t, open: evt.open, high: evt.high, low: evt.low, close: evt.close })
if (arr.length > 2000) arr.shift()
}
next[sym] = arr
}
return next
})
}, [lastBySymbol])
const data1 = useMemo(() => {
const base = history1m[symbol1] || []
return tf1 === '1m' ? base : resampleFrom1m(base, tf1)
}, [history1m, symbol1, tf1])
const data2 = useMemo(() => {
const base = history1m[symbol2] || []
return tf2 === '1m' ? base : resampleFrom1m(base, tf2)
}, [history1m, symbol2, tf2])
return (
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr', paddingBottom: 16 }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 8px 0' }}>
<SymbolTimeframeSelector symbol={symbol1} timeframe={tf1} onChangeSymbol={setSymbol1} onChangeTimeframe={setTf1} />
{isConnected ? <span style={{ color: '#10b981' }}> SSE</span> : <span style={{ color: '#ef4444' }}> SSE</span>}
</div>
<SimpleKlineChart title={`${symbol1}${tf1} (SSE)`} data={data1.map(b => ({ ...b, time: b.time as any }))} />
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 8px 0' }}>
<SymbolTimeframeSelector symbol={symbol2} timeframe={tf2} onChangeSymbol={setSymbol2} onChangeTimeframe={setTf2} />
{isConnected ? <span style={{ color: '#10b981' }}> SSE</span> : <span style={{ color: '#ef4444' }}> SSE</span>}
</div>
<SimpleKlineChart title={`${symbol2}${tf2} (SSE)`} data={data2.map(b => ({ ...b, time: b.time as any }))} />
</div>
</div>
)
}
@@ -0,0 +1,81 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import SimpleKlineChart from './SimpleKlineChart'
import WatchlistPanel, { useWatchlist } from './WatchlistPanel'
import { useSSEMultiplexer } from '@/hooks/useSSEMultiplexer'
import { marketDataApi } from '@/services/api'
type Bar = { time: number; open: number; high: number; low: number; close: number }
export default function MultiChartSSEPanel() {
const { symbols, setSymbols } = useWatchlist()
const { isConnected, lastBySymbol } = useSSEMultiplexer(symbols, '1m')
const [history, setHistory] = useState<Record<string, Bar[]>>({})
const [lastBars, setLastBars] = useState<Record<string, Bar | undefined>>({})
// Load initial 1m history for each symbol
useEffect(() => {
let cancelled = false
;(async () => {
const next: Record<string, Bar[]> = {}
for (const s of symbols) {
try {
const rows = await marketDataApi.getOHLCV(s, '1m', 1000)
next[s] = rows.map(r => ({ time: r.time, open: r.open, high: r.high, low: r.low, close: r.close }))
} catch {
next[s] = []
}
}
if (!cancelled) setHistory(next)
})()
return () => { cancelled = true }
}, [symbols.join(',')])
// Apply incoming SSE events to lastBars and extend history when a new minute closes
useEffect(() => {
const updated: Record<string, Bar | undefined> = {}
const newHistory: Record<string, Bar[]> = { ...history }
for (const [sym, evt] of Object.entries(lastBySymbol)) {
if (!evt) continue
const t = Math.floor(Date.parse(evt.close_time) / 1000)
const bar: Bar = { time: t, open: evt.open, high: evt.high, low: evt.low, close: evt.close }
updated[sym] = bar
// Append or update last element of history if new minute
const arr = newHistory[sym] || []
if (arr.length && arr[arr.length - 1].time === bar.time) {
arr[arr.length - 1] = bar
} else if (!arr.length || arr[arr.length - 1].time < bar.time) {
arr.push(bar)
if (arr.length > 2000) arr.shift()
}
newHistory[sym] = arr
}
if (Object.keys(updated).length) {
setLastBars(prev => ({ ...prev, ...updated }))
setHistory(newHistory)
}
}, [lastBySymbol])
const onWatchlistChange = useCallback((s: string[]) => setSymbols(s), [setSymbols])
return (
<div style={{ display: 'grid', gap: 16 }}>
<WatchlistPanel onChange={onWatchlistChange} />
<div className="card" style={{ padding: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span className="title">Live SSE Watchlist</span>
{isConnected ? <span style={{ color: '#10b981' }}> Connected</span> : <span style={{ color: '#ef4444' }}> Disconnected</span>}
</div>
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr', }}>
{symbols.map(sym => (
<SimpleKlineChart
key={sym}
title={`${sym} — 1m (SSE)`}
data={(history[sym] || []).map(b => ({ time: b.time as any, open: b.open, high: b.high, low: b.low, close: b.close }))}
lastBar={lastBars[sym] ? { ...lastBars[sym], time: lastBars[sym]!.time as any } : undefined}
/>
))}
</div>
</div>
</div>
)
}
+243
View File
@@ -0,0 +1,243 @@
import { useEffect, useState } from 'react';
import { Newspaper, TrendingUp, TrendingDown, Minus, ExternalLink, RefreshCw } from 'lucide-react';
import type { NewsFeed as NewsFeedType, NewsArticle, Sentiment } from '@/types';
import { newsApi } from '@/services/api';
export default function NewsFeed() {
const [newsFeed, setNewsFeed] = useState<NewsFeedType | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState<'ALL' | Sentiment>('ALL');
const [autoRefresh, setAutoRefresh] = useState(false);
const loadNews = async () => {
try {
setIsLoading(true);
const data = await newsApi.getNewsFeed(50);
setNewsFeed(data);
} catch (error) {
console.error('Error loading news:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadNews();
}, []);
useEffect(() => {
if (autoRefresh) {
const interval = setInterval(loadNews, 300000); // Refresh every 5 minutes
return () => clearInterval(interval);
}
}, [autoRefresh]);
const getSentimentIcon = (sentiment: Sentiment) => {
switch (sentiment) {
case 'POSITIVE':
return <TrendingUp className="w-4 h-4 text-green-500" />;
case 'NEGATIVE':
return <TrendingDown className="w-4 h-4 text-red-500" />;
case 'NEUTRAL':
return <Minus className="w-4 h-4 text-gray-400" />;
}
};
const getSentimentColor = (sentiment: Sentiment) => {
switch (sentiment) {
case 'POSITIVE':
return 'border-l-green-500 bg-green-500/5';
case 'NEGATIVE':
return 'border-l-red-500 bg-red-500/5';
case 'NEUTRAL':
return 'border-l-gray-500 bg-gray-500/5';
}
};
const getImpactBadge = (impact: string) => {
const colors = {
HIGH: 'bg-red-500/20 text-red-500',
MEDIUM: 'bg-yellow-500/20 text-yellow-500',
LOW: 'bg-blue-500/20 text-blue-500',
};
return colors[impact as keyof typeof colors] || colors.LOW;
};
const filteredArticles = newsFeed?.articles.filter(
(article) => filter === 'ALL' || article.sentiment === filter
) || [];
return (
<div className="card">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Newspaper className="w-6 h-6 text-blue-500" />
<div>
<h3 className="text-lg font-semibold">Market News</h3>
{newsFeed && (
<p className="text-xs text-gray-400">
{newsFeed.total_count} articles Overall: {newsFeed.overall_sentiment}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadNews}
disabled={isLoading}
className="p-2 rounded-md hover:bg-dark-hover transition-colors"
title="Refresh news"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded"
/>
<span className="text-gray-400">Auto</span>
</label>
</div>
</div>
{newsFeed && (
<div className="mb-4">
<div className="grid grid-cols-4 gap-2 text-sm">
<button
onClick={() => setFilter('ALL')}
className={`px-3 py-2 rounded-md transition-colors ${
filter === 'ALL' ? 'bg-blue-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
All ({newsFeed.total_count})
</button>
<button
onClick={() => setFilter('POSITIVE')}
className={`px-3 py-2 rounded-md transition-colors ${
filter === 'POSITIVE' ? 'bg-green-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
Bullish ({newsFeed.bullish_count})
</button>
<button
onClick={() => setFilter('NEGATIVE')}
className={`px-3 py-2 rounded-md transition-colors ${
filter === 'NEGATIVE' ? 'bg-red-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
Bearish ({newsFeed.bearish_count})
</button>
<button
onClick={() => setFilter('NEUTRAL')}
className={`px-3 py-2 rounded-md transition-colors ${
filter === 'NEUTRAL' ? 'bg-gray-600' : 'bg-dark-bg hover:bg-dark-hover'
}`}
>
Neutral ({newsFeed.neutral_count})
</button>
</div>
</div>
)}
<div className="space-y-3 max-h-96 overflow-y-auto">
{isLoading && !newsFeed ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
) : filteredArticles.length > 0 ? (
filteredArticles.map((article) => (
<NewsArticleCard key={article.id} article={article} />
))
) : (
<p className="text-center text-gray-400 py-8">No news articles found</p>
)}
</div>
</div>
);
}
function NewsArticleCard({ article }: { article: NewsArticle }) {
const getSentimentColor = (sentiment: Sentiment) => {
switch (sentiment) {
case 'POSITIVE':
return 'border-l-green-500 bg-green-500/5';
case 'NEGATIVE':
return 'border-l-red-500 bg-red-500/5';
case 'NEUTRAL':
return 'border-l-gray-500 bg-gray-500/5';
}
};
const getSentimentIcon = (sentiment: Sentiment) => {
switch (sentiment) {
case 'POSITIVE':
return <TrendingUp className="w-4 h-4 text-green-500" />;
case 'NEGATIVE':
return <TrendingDown className="w-4 h-4 text-red-500" />;
case 'NEUTRAL':
return <Minus className="w-4 h-4 text-gray-400" />;
}
};
const getImpactBadge = (impact: string) => {
const colors = {
HIGH: 'bg-red-500/20 text-red-500',
MEDIUM: 'bg-yellow-500/20 text-yellow-500',
LOW: 'bg-blue-500/20 text-blue-500',
};
return colors[impact as keyof typeof colors] || colors.LOW;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
if (diffMins < 60) {
return `${diffMins}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else {
return date.toLocaleDateString();
}
};
return (
<div
className={`p-3 rounded-md border-l-4 ${getSentimentColor(
article.sentiment
)} hover:bg-dark-hover transition-colors cursor-pointer`}
onClick={() => window.open(article.url, '_blank')}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{getSentimentIcon(article.sentiment)}
<span className="text-xs font-medium text-gray-500">{article.source}</span>
<span className={`text-xs px-2 py-0.5 rounded ${getImpactBadge(article.impact_on_gold)}`}>
{article.impact_on_gold}
</span>
<span className="text-xs text-gray-500">{formatDate(article.published_at)}</span>
</div>
<h4 className="font-medium text-sm mb-1 line-clamp-2">{article.title}</h4>
{article.description && (
<p className="text-xs text-gray-400 line-clamp-2">{article.description}</p>
)}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs px-2 py-0.5 rounded bg-dark-bg text-gray-400">
{article.category}
</span>
<span className="text-xs text-gray-500">
Relevance: {(article.relevance_score * 100).toFixed(0)}%
</span>
</div>
</div>
<ExternalLink className="w-4 h-4 text-gray-500 flex-shrink-0" />
</div>
</div>
);
}
@@ -0,0 +1,140 @@
import { TrendingUp, TrendingDown, DollarSign, Activity } from 'lucide-react';
import type { Portfolio } from '@/types';
import { formatPrice, formatPercent, formatNumber } from '@/utils/indicators';
interface PortfolioTrackerProps {
portfolio: Portfolio;
}
export default function PortfolioTracker({ portfolio }: PortfolioTrackerProps) {
const isProfitable = portfolio.totalPnl >= 0;
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Portfolio</h3>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">Total Value</span>
</div>
<p className="text-2xl font-bold">{formatPrice(portfolio.totalValue)}</p>
</div>
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
{isProfitable ? (
<TrendingUp className="w-4 h-4 text-green-500" />
) : (
<TrendingDown className="w-4 h-4 text-red-500" />
)}
<span className="text-sm text-gray-400">Total P&L</span>
</div>
<p
className={`text-2xl font-bold ${
isProfitable ? 'text-green-500' : 'text-red-500'
}`}
>
{formatPrice(portfolio.totalPnl)}
</p>
<p
className={`text-sm ${isProfitable ? 'text-green-500' : 'text-red-500'}`}
>
{formatPercent(portfolio.totalPnlPercent)}
</p>
</div>
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">Cash</span>
</div>
<p className="text-xl font-semibold">{formatPrice(portfolio.cash)}</p>
</div>
<div className="bg-dark-bg rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<Activity className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">Trades</span>
</div>
<p className="text-xl font-semibold">{portfolio.trades.length}</p>
</div>
</div>
{portfolio.position && (
<div className="bg-dark-bg rounded-lg p-4 mb-4">
<h4 className="font-semibold mb-3">Current Position</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Symbol</span>
<span className="font-medium">{portfolio.position.symbol}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Quantity</span>
<span className="font-medium">{formatNumber(portfolio.position.quantity)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Avg Price</span>
<span className="font-medium">{formatPrice(portfolio.position.avgPrice)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Current Price</span>
<span className="font-medium">{formatPrice(portfolio.position.currentPrice)}</span>
</div>
<div className="flex justify-between border-t border-dark-border pt-2">
<span className="text-gray-400">Unrealized P&L</span>
<span
className={`font-semibold ${
portfolio.position.unrealizedPnl >= 0 ? 'text-green-500' : 'text-red-500'
}`}
>
{formatPrice(portfolio.position.unrealizedPnl)} (
{formatPercent(portfolio.position.unrealizedPnlPercent)})
</span>
</div>
</div>
</div>
)}
{portfolio.trades.length > 0 && (
<div>
<h4 className="font-semibold mb-3">Recent Trades</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{portfolio.trades.slice(-5).reverse().map((trade) => (
<div
key={trade.id}
className="bg-dark-bg rounded-lg p-3 text-sm flex justify-between items-center"
>
<div>
<span
className={`font-semibold ${
trade.action === 'BUY' ? 'text-green-500' : 'text-red-500'
}`}
>
{trade.action}
</span>
<span className="text-gray-400 ml-2">
{formatNumber(trade.quantity)} @ {formatPrice(trade.price)}
</span>
</div>
<div className="text-right">
<p className="font-medium">{formatPrice(trade.total)}</p>
{trade.pnl !== undefined && (
<p
className={`text-xs ${
trade.pnl >= 0 ? 'text-green-500' : 'text-red-500'
}`}
>
{formatPrice(trade.pnl)}
</p>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react'
import { promptsApi } from '@/services/api'
export default function PromptTemplatesPanel() {
const [list, setList] = useState<any[]>([])
const [active, setActive] = useState<any | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let mounted = true
;(async () => {
try {
setLoading(true)
const rows = await promptsApi.list()
if (mounted) setList(rows)
if (rows?.[0]) setActive(await promptsApi.get(rows[0].name))
} catch (e: any) {
setError(e?.message || 'Failed to load prompt templates')
} finally {
setLoading(false)
}
})()
return () => { mounted = false }
}, [])
const load = async (name: string) => {
try { setActive(await promptsApi.get(name)) } catch {}
}
return (
<div className="card" style={{ display: 'grid', gap: 12 }}>
<div className="title">Prompt Templates</div>
{loading && <div className="text-gray-400">Loading</div>}
{error && <div className="text-red-500">{error}</div>}
{!loading && !error && (
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: '1fr 2fr' }}>
<div className="card">
<div className="text-gray-300 font-semibold mb-2">Templates</div>
<ul className="space-y-1">
{list.map(t => (
<li key={t.name}>
<button className="btn bg-dark-bg w-full text-left" onClick={() => load(t.name)}>
<div className="font-medium">{t.name}</div>
<div className="text-gray-400 text-xs">{t.description}</div>
</button>
</li>
))}
</ul>
</div>
<div className="card">
{active ? (
<div>
<div className="text-gray-300 font-semibold">{active.name}</div>
<div className="text-gray-400 text-sm mb-2">Vars: {active.variables?.join(', ') || '-'}</div>
<pre className="whitespace-pre-wrap text-gray-100 text-sm bg-dark-bg p-3 rounded border border-dark-border">{active.body}</pre>
</div>
) : (
<div className="text-gray-400">Select a template</div>
)}
</div>
</div>
)}
</div>
)
}
+232
View File
@@ -0,0 +1,232 @@
import { useState, useEffect } from 'react';
import { Shield, Calculator, TrendingUp, AlertTriangle } from 'lucide-react';
import { calculatePositionSize, formatPrice } from '@/utils/indicators';
interface RiskManagementProps {
currentPrice: number;
cash: number;
position: any;
trades: any[];
onSetStopLoss?: (price: number) => void;
onSetTakeProfit?: (price: number) => void;
}
export default function RiskManagement({
currentPrice,
cash,
position: _position,
trades,
onSetStopLoss,
onSetTakeProfit,
}: RiskManagementProps) {
const [riskPercent, setRiskPercent] = useState<number>(2);
const [stopLossPercent, setStopLossPercent] = useState<number>(2);
const [takeProfitPercent, setTakeProfitPercent] = useState<number>(4);
const [positionSize, setPositionSize] = useState<number>(0);
const [stopLossPrice, setStopLossPrice] = useState<number>(0);
const [takeProfitPrice, setTakeProfitPrice] = useState<number>(0);
// Calculate position size based on risk
useEffect(() => {
const riskAmount = cash * (riskPercent / 100);
const stopLossDiff = currentPrice * (stopLossPercent / 100);
const maxQuantity = riskAmount / stopLossDiff;
setPositionSize(Number(maxQuantity.toFixed(4)));
setStopLossPrice(Number((currentPrice * (1 - stopLossPercent / 100)).toFixed(2)));
setTakeProfitPrice(Number((currentPrice * (1 + takeProfitPercent / 100)).toFixed(2)));
}, [currentPrice, cash, riskPercent, stopLossPercent, takeProfitPercent]);
// Calculate Kelly Criterion recommendation
const getKellyRecommendation = () => {
if (trades.length < 10) {
return { size: 0, message: 'Need at least 10 trades for Kelly calculation' };
}
const completedTrades = trades.filter((t: any) => t.pnl !== undefined);
const winningTrades = completedTrades.filter((t: any) => t.pnl > 0);
const losingTrades = completedTrades.filter((t: any) => t.pnl <= 0);
if (winningTrades.length === 0 || losingTrades.length === 0) {
return { size: 0, message: 'Need both winning and losing trades' };
}
const winRate = winningTrades.length / completedTrades.length;
const avgWin = winningTrades.reduce((sum: number, t: any) => sum + t.pnl, 0) / winningTrades.length;
const avgLoss = Math.abs(losingTrades.reduce((sum: number, t: any) => sum + t.pnl, 0) / losingTrades.length);
const kellySize = calculatePositionSize(cash, winRate, avgWin, avgLoss);
return {
size: kellySize,
message: `Kelly suggests ${formatPrice(kellySize)} position size`,
};
};
const kellyRec = getKellyRecommendation();
const maxLoss = positionSize * (currentPrice * (stopLossPercent / 100));
const maxProfit = positionSize * (currentPrice * (takeProfitPercent / 100));
const riskRewardRatio = takeProfitPercent / stopLossPercent;
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-blue-500" />
Risk Management
</h3>
{/* Risk Parameters */}
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">
Risk per Trade (% of Capital)
</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0.5"
max="5"
step="0.5"
value={riskPercent}
onChange={(e) => setRiskPercent(Number(e.target.value))}
className="flex-1"
/>
<span className="text-sm font-medium w-12">{riskPercent}%</span>
</div>
<p className="text-xs text-gray-500 mt-1">
Max risk: {formatPrice(cash * (riskPercent / 100))}
</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Stop Loss (%)</label>
<div className="flex items-center gap-2">
<input
type="range"
min="0.5"
max="10"
step="0.5"
value={stopLossPercent}
onChange={(e) => setStopLossPercent(Number(e.target.value))}
className="flex-1"
/>
<span className="text-sm font-medium w-12">{stopLossPercent}%</span>
</div>
<p className="text-xs text-gray-500 mt-1">
Stop at: {formatPrice(stopLossPrice)}
</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Take Profit (%)</label>
<div className="flex items-center gap-2">
<input
type="range"
min="1"
max="20"
step="0.5"
value={takeProfitPercent}
onChange={(e) => setTakeProfitPercent(Number(e.target.value))}
className="flex-1"
/>
<span className="text-sm font-medium w-12">{takeProfitPercent}%</span>
</div>
<p className="text-xs text-gray-500 mt-1">
Target: {formatPrice(takeProfitPrice)}
</p>
</div>
</div>
{/* Recommended Position Size */}
<div className="mt-4 p-4 bg-dark-bg rounded-lg border border-dark-border">
<div className="flex items-center gap-2 mb-3">
<Calculator className="w-4 h-4 text-blue-500" />
<h4 className="font-semibold">Recommended Position</h4>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">Position Size:</span>
<span className="font-bold">{positionSize.toFixed(4)} oz</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Cost:</span>
<span className="font-medium">{formatPrice(positionSize * currentPrice)}</span>
</div>
<div className="flex justify-between border-t border-dark-border pt-2">
<span className="text-gray-400">Max Loss:</span>
<span className="font-semibold text-red-500">-{formatPrice(maxLoss)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Max Profit:</span>
<span className="font-semibold text-green-500">+{formatPrice(maxProfit)}</span>
</div>
<div className="flex justify-between border-t border-dark-border pt-2">
<span className="text-gray-400">R:R Ratio:</span>
<span
className={`font-semibold ${
riskRewardRatio >= 2 ? 'text-green-500' : 'text-yellow-500'
}`}
>
1:{riskRewardRatio.toFixed(2)}
</span>
</div>
</div>
</div>
{/* Kelly Criterion */}
{trades.length >= 10 && (
<div className="mt-4 p-4 bg-blue-500/10 rounded-lg border border-blue-500/30">
<div className="flex items-center gap-2 mb-2">
<TrendingUp className="w-4 h-4 text-blue-500" />
<h4 className="font-semibold text-sm">Kelly Criterion</h4>
</div>
<p className="text-xs text-gray-300 mb-2">{kellyRec.message}</p>
{kellyRec.size > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-400">Suggested oz:</span>
<span className="font-medium">
{(kellyRec.size / currentPrice).toFixed(4)} oz
</span>
</div>
)}
</div>
)}
{/* Risk Warning */}
<div className="mt-4 p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
<div className="text-xs text-gray-300">
<p className="font-medium text-yellow-500 mb-1">Risk Guidelines:</p>
<ul className="space-y-1 list-disc list-inside">
<li>Never risk more than 2% per trade</li>
<li>Maintain R:R ratio of at least 1:2</li>
<li>Always use stop losses</li>
<li>Consider Kelly Criterion for sizing</li>
</ul>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="mt-4 space-y-2">
<button
onClick={() => onSetStopLoss?.(stopLossPrice)}
className="btn w-full bg-red-600 hover:bg-red-700 text-white flex items-center justify-center gap-2"
>
<Shield className="w-4 h-4" />
Set Stop Loss ({formatPrice(stopLossPrice)})
</button>
<button
onClick={() => onSetTakeProfit?.(takeProfitPrice)}
className="btn w-full bg-green-600 hover:bg-green-700 text-white flex items-center justify-center gap-2"
>
<TrendingUp className="w-4 h-4" />
Set Take Profit ({formatPrice(takeProfitPrice)})
</button>
</div>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useState } from 'react'
import { settingsApi } from '@/services/api'
export default function SettingsPanel() {
const [models, setModels] = useState<any>({})
const [exchanges, setExchanges] = useState<any>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let mounted = true
;(async () => {
try {
setLoading(true)
const [m, e] = await Promise.all([settingsApi.getModels(), settingsApi.getExchanges()])
if (mounted) { setModels(m); setExchanges(e) }
} catch (err: any) {
setError(err?.message || 'Failed to load settings')
} finally {
setLoading(false)
}
})()
return () => { mounted = false }
}, [])
const saveModels = async () => {
setSaving(true)
try { setModels(await settingsApi.putModels(models)) } finally { setSaving(false) }
}
const saveExchanges = async () => {
setSaving(true)
try { setExchanges(await settingsApi.putExchanges(exchanges)) } finally { setSaving(false) }
}
return (
<div className="card" style={{ display: 'grid', gap: 12 }}>
<div className="title">Settings</div>
{loading && <div className="text-gray-400">Loading</div>}
{error && <div className="text-red-500">{error}</div>}
{!loading && !error && (
<div style={{ display: 'grid', gap: 16 }}>
<section className="card">
<div className="title">Models</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<LabeledInput label="Default Model" value={models.default_model || ''} onChange={v => setModels({ ...models, default_model: v })} />
<LabeledInput label="Temperature" type="number" value={models.temperature ?? 0.3} onChange={v => setModels({ ...models, temperature: Number(v) })} />
<LabeledInput label="Max Tokens" type="number" value={models.max_tokens ?? 800} onChange={v => setModels({ ...models, max_tokens: Number(v) })} />
</div>
<div className="mt-3">
<button className="btn-primary" onClick={saveModels} disabled={saving}>{saving ? 'Saving…' : 'Save Models'}</button>
</div>
</section>
<section className="card">
<div className="title">Exchanges</div>
<Toggle label="Binance Enabled" checked={exchanges?.binance?.enabled ?? true} onChange={v => setExchanges({ ...exchanges, binance: { ...(exchanges.binance || {}), enabled: v } })} />
<Toggle label="Alpha Vantage Enabled" checked={exchanges?.alpha_vantage?.enabled ?? true} onChange={v => setExchanges({ ...exchanges, alpha_vantage: { ...(exchanges.alpha_vantage || {}), enabled: v } })} />
<div className="mt-3">
<button className="btn-primary" onClick={saveExchanges} disabled={saving}>{saving ? 'Saving…' : 'Save Exchanges'}</button>
</div>
</section>
</div>
)}
</div>
)
}
function LabeledInput({ label, value, onChange, type = 'text' }: { label: string; value: any; onChange: (v: string) => void; type?: string }) {
return (
<label className="flex flex-col gap-1">
<span className="text-gray-300 text-sm">{label}</span>
<input className="input" type={type} value={value} onChange={e => onChange(e.target.value)} />
</label>
)
}
function Toggle({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center gap-2">
<input type="checkbox" checked={checked} onChange={e => onChange(e.target.checked)} />
<span className="text-gray-300 text-sm">{label}</span>
</label>
)
}
@@ -0,0 +1,51 @@
import { useEffect, useRef } from 'react'
import { createChart, IChartApi, ISeriesApi } from 'lightweight-charts'
type Bar = { time: any; open: number; high: number; low: number; close: number }
export default function SimpleKlineChart({ title, data, lastBar }: { title: string; data: Bar[]; lastBar?: Bar }) {
const containerRef = useRef<HTMLDivElement | null>(null)
const chartRef = useRef<IChartApi | null>(null)
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
useEffect(() => {
if (!containerRef.current) return
const chart = createChart(containerRef.current, {
layout: { background: { color: '#0f172a' }, textColor: '#e2e8f0' },
grid: { vertLines: { color: '#1f2937' }, horzLines: { color: '#1f2937' } },
rightPriceScale: { borderColor: '#1f2937' },
timeScale: { borderColor: '#1f2937', timeVisible: true, secondsVisible: false },
height: 280,
width: containerRef.current.clientWidth,
})
chartRef.current = chart
const series = chart.addCandlestickSeries({
upColor: '#16a34a', downColor: '#dc2626', borderUpColor: '#16a34a', borderDownColor: '#dc2626', wickUpColor: '#16a34a', wickDownColor: '#dc2626',
})
seriesRef.current = series
const onResize = () => {
if (!containerRef.current || !chartRef.current) return
chartRef.current.applyOptions({ width: containerRef.current.clientWidth })
}
window.addEventListener('resize', onResize)
return () => { window.removeEventListener('resize', onResize); chart.remove() }
}, [])
useEffect(() => {
if (!seriesRef.current) return
seriesRef.current.setData(data)
}, [data])
useEffect(() => {
if (!seriesRef.current || !lastBar) return
seriesRef.current.update(lastBar)
}, [lastBar])
return (
<div className="card">
<div className="title">{title}</div>
<div ref={containerRef} className="chart" />
</div>
)
}
@@ -0,0 +1,44 @@
import { useEffect, useRef } from 'react'
import { createChart, IChartApi, ISeriesApi } from 'lightweight-charts'
type Point = { time: number; value: number }
export default function SimpleLineChart({ title, data }: { title: string; data: Point[] }) {
const containerRef = useRef<HTMLDivElement | null>(null)
const chartRef = useRef<IChartApi | null>(null)
const seriesRef = useRef<ISeriesApi<'Line'> | null>(null)
useEffect(() => {
if (!containerRef.current) return
const chart = createChart(containerRef.current, {
layout: { background: { color: '#0f172a' }, textColor: '#e2e8f0' },
grid: { vertLines: { color: '#1f2937' }, horzLines: { color: '#1f2937' } },
rightPriceScale: { borderColor: '#1f2937' },
timeScale: { borderColor: '#1f2937', timeVisible: true, secondsVisible: false },
height: 280,
width: containerRef.current.clientWidth,
})
chartRef.current = chart
const series = chart.addLineSeries({ color: '#3b82f6', lineWidth: 2 })
seriesRef.current = series
const onResize = () => {
if (!containerRef.current || !chartRef.current) return
chartRef.current.applyOptions({ width: containerRef.current.clientWidth })
}
window.addEventListener('resize', onResize)
return () => { window.removeEventListener('resize', onResize); chart.remove() }
}, [])
useEffect(() => {
if (!seriesRef.current) return
seriesRef.current.setData(data.map(d => ({ time: d.time as any, value: d.value })))
}, [data])
return (
<div className="card">
<div className="title">{title}</div>
<div ref={containerRef} className="chart" />
</div>
)
}
@@ -0,0 +1,51 @@
import React from 'react'
const SYMBOLS = [
{ label: 'BTC/USDT (Binance)', value: 'BTCUSDT' },
{ label: 'ETH/USDT (Binance)', value: 'ETHUSDT' },
{ label: 'XAU/USD (Gold)', value: 'XAUUSD' },
]
const TIMEFRAMES = [
{ label: '1m', value: '1m' },
{ label: '5m', value: '5m' },
{ label: '1h', value: '1h' },
{ label: '4h', value: '4h' },
]
export default function SymbolTimeframeSelector({
symbol,
timeframe,
onChangeSymbol,
onChangeTimeframe,
}: {
symbol: string
timeframe: string
onChangeSymbol: (v: string) => void
onChangeTimeframe: (v: string) => void
}) {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<select
className="input"
value={symbol}
onChange={(e) => onChangeSymbol(e.target.value)}
aria-label="Symbol"
>
{SYMBOLS.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<select
className="input"
value={timeframe}
onChange={(e) => onChangeTimeframe(e.target.value)}
aria-label="Timeframe"
>
{TIMEFRAMES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
)
}
+296
View File
@@ -0,0 +1,296 @@
import { useState, ReactNode } from 'react';
import { X, Maximize2, Minimize2, Pin, PinOff } from 'lucide-react';
import ComponentSettings from './ComponentSettings';
import type { TabConfig, LayoutMode, TabCustomization } from '@/types';
interface TabPanel {
config: TabConfig;
content: ReactNode;
}
interface TabbedContainerProps {
panels: TabPanel[];
mode: LayoutMode;
onTabClose?: (tabId: string) => void;
onTabPin?: (tabId: string) => void;
onCustomizationUpdate?: (tabId: string, customization: TabCustomization) => void;
className?: string;
}
export default function TabbedContainer({
panels,
mode,
onTabClose,
onTabPin,
onCustomizationUpdate,
className = '',
}: TabbedContainerProps) {
const [activeTabId, setActiveTabId] = useState<string>(
panels.find((p) => p.config.visible)?.config.id || panels[0]?.config.id
);
const [expandedTab, setExpandedTab] = useState<string | null>(null);
const visiblePanels = panels
.filter((p) => p.config.visible)
.sort((a, b) => a.config.order - b.config.order);
const activePanel = visiblePanels.find((p) => p.config.id === activeTabId);
const handleExpand = (tabId: string) => {
setExpandedTab(expandedTab === tabId ? null : tabId);
};
// Tabs mode - single panel with tab switcher
if (mode === 'tabs') {
return (
<div className={`flex flex-col h-full ${className}`}>
{/* Tab switcher */}
<div className="flex items-center gap-1 border-b border-gray-700 bg-dark-card px-2 overflow-x-auto">
{visiblePanels.map((panel) => (
<button
key={panel.config.id}
onClick={() => setActiveTabId(panel.config.id)}
className={`flex items-center gap-2 px-4 py-3 border-b-2 transition-colors whitespace-nowrap ${
activeTabId === panel.config.id
? 'border-gold-500 text-gold-500 bg-dark-hover'
: 'border-transparent text-gray-400 hover:text-white hover:bg-dark-hover'
}`}
>
{panel.config.pinned && <Pin className="w-3 h-3" />}
<span>{panel.config.label}</span>
{onTabClose && !panel.config.pinned && (
<button
onClick={(e) => {
e.stopPropagation();
onTabClose(panel.config.id);
}}
className="p-0.5 hover:bg-red-500/20 rounded"
>
<X className="w-3 h-3" />
</button>
)}
</button>
))}
</div>
{/* Active panel content */}
<div className="flex-1 overflow-hidden">
{activePanel && (
<div className="h-full">{activePanel.content}</div>
)}
</div>
</div>
);
}
// Split mode - two main sections
if (mode === 'split') {
const leftPanels = visiblePanels.filter((p) => p.config.position !== 'right');
const rightPanels = visiblePanels.filter((p) => p.config.position === 'right');
return (
<div className={`grid grid-cols-2 gap-6 h-full ${className}`}>
{/* Left section */}
<div className="space-y-6 overflow-y-auto">
{leftPanels.map((panel) => (
<PanelCard
key={panel.config.id}
panel={panel}
onExpand={handleExpand}
onPin={onTabPin}
onClose={onTabClose}
onCustomizationUpdate={onCustomizationUpdate}
isExpanded={expandedTab === panel.config.id}
/>
))}
</div>
{/* Right section */}
<div className="space-y-6 overflow-y-auto">
{rightPanels.map((panel) => (
<PanelCard
key={panel.config.id}
panel={panel}
onExpand={handleExpand}
onPin={onTabPin}
onClose={onTabClose}
onCustomizationUpdate={onCustomizationUpdate}
isExpanded={expandedTab === panel.config.id}
/>
))}
</div>
</div>
);
}
// Grid mode - responsive grid layout
const getSizeClass = (size: TabConfig['size']) => {
switch (size) {
case 'small':
return 'col-span-1';
case 'medium':
return 'col-span-1 lg:col-span-2';
case 'large':
return 'col-span-1 lg:col-span-3';
case 'full':
return 'col-span-full';
default:
return 'col-span-1';
}
};
return (
<div className={`grid grid-cols-1 lg:grid-cols-6 gap-6 ${className}`}>
{visiblePanels.map((panel) => (
<div
key={panel.config.id}
className={`${getSizeClass(panel.config.size)} ${
expandedTab === panel.config.id ? 'fixed inset-4 z-40' : ''
}`}
>
<PanelCard
panel={panel}
onExpand={handleExpand}
onPin={onTabPin}
onClose={onTabClose}
onCustomizationUpdate={onCustomizationUpdate}
isExpanded={expandedTab === panel.config.id}
/>
</div>
))}
</div>
);
}
// Individual panel card component
interface PanelCardProps {
panel: TabPanel;
onExpand: (tabId: string) => void;
onPin?: (tabId: string) => void;
onClose?: (tabId: string) => void;
onCustomizationUpdate?: (tabId: string, customization: TabCustomization) => void;
isExpanded: boolean;
}
function PanelCard({ panel, onExpand, onPin, onClose, onCustomizationUpdate, isExpanded }: PanelCardProps) {
const getAvailableSettings = (tabId: string) => {
// Define available settings per tab
switch (tabId) {
case 'news':
return {
autoRefresh: true,
refreshRate: true,
filters: {
sentiment: ['ALL', 'POSITIVE', 'NEGATIVE', 'NEUTRAL'],
impact: ['ALL', 'HIGH', 'MEDIUM', 'LOW'],
},
theme: true,
};
case 'alerts':
return {
autoRefresh: true,
refreshRate: true,
filters: {
severity: ['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'],
type: ['ALL', 'PRICE_SPIKE', 'NEWS_BREAKING', 'SUPPORT_BREACH'],
},
};
case 'chart':
return {
displayMode: ['candlestick', 'line', 'area'],
theme: true,
};
case 'analytics':
return {
displayMode: ['detailed', 'compact', 'charts-only'],
theme: true,
};
case 'daily-checklist':
return {
autoRefresh: false,
displayMode: ['all-phases', 'current-phase-only'],
theme: true,
};
case 'trading-journal':
return {
displayMode: ['detailed', 'compact', 'list'],
filters: {
emotion: ['all', 'confident', 'neutral', 'anxious'],
result: ['all', 'winners', 'losers'],
},
};
case 'market-summary':
return {
autoRefresh: true,
refreshRate: true,
displayMode: ['overview', 'levels', 'events', 'ai'],
};
default:
return { theme: true };
}
};
return (
<div
className={`card flex flex-col group ${
isExpanded ? 'h-full' : 'h-auto'
}`}
>
{/* Panel header */}
<div className="flex items-center justify-between mb-4 pb-3 border-b border-gray-700">
<h3 className="text-lg font-semibold flex items-center gap-2">
{panel.config.pinned && <Pin className="w-4 h-4 text-gold-500" />}
{panel.config.label}
</h3>
<div className="flex items-center gap-1">
{onCustomizationUpdate && (
<ComponentSettings
customization={panel.config.customization}
onUpdate={(customization) =>
onCustomizationUpdate(panel.config.id, customization)
}
availableSettings={getAvailableSettings(panel.config.id)}
/>
)}
{onPin && (
<button
onClick={() => onPin(panel.config.id)}
className="p-1.5 hover:bg-dark-hover rounded transition-colors"
title={panel.config.pinned ? 'Unpin' : 'Pin'}
>
{panel.config.pinned ? (
<PinOff className="w-4 h-4" />
) : (
<Pin className="w-4 h-4" />
)}
</button>
)}
<button
onClick={() => onExpand(panel.config.id)}
className="p-1.5 hover:bg-dark-hover rounded transition-colors"
title={isExpanded ? 'Minimize' : 'Expand'}
>
{isExpanded ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</button>
{onClose && !panel.config.pinned && (
<button
onClick={() => onClose(panel.config.id)}
className="p-1.5 hover:bg-red-500/20 rounded transition-colors"
title="Close"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* Panel content */}
<div className={`flex-1 ${isExpanded ? 'overflow-y-auto' : ''}`}>
{panel.content}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { Clock } from 'lucide-react';
interface TimeframeSelectorProps {
selected: string;
onChange: (timeframe: string) => void;
disabled?: boolean;
}
export default function TimeframeSelector({
selected,
onChange,
disabled = false,
}: TimeframeSelectorProps) {
const timeframes = [
{ value: '1min', label: '1M', description: 'Scalping' },
{ value: '5min', label: '5M', description: 'Intraday' },
{ value: '15min', label: '15M', description: 'Intraday' },
{ value: '30min', label: '30M', description: 'Short-term' },
{ value: '60min', label: '1H', description: 'Hourly' },
{ value: '4hour', label: '4H', description: 'Swing' },
{ value: 'daily', label: '1D', description: 'Daily' },
{ value: 'weekly', label: '1W', description: 'Long-term' },
];
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-400">Timeframe:</span>
</div>
<div className="flex gap-1">
{timeframes.map((tf) => (
<button
key={tf.value}
onClick={() => onChange(tf.value)}
disabled={disabled}
className={`
px-3 py-1.5 text-sm font-medium rounded transition-colors
${
selected === tf.value
? 'bg-blue-600 text-white'
: 'bg-dark-bg text-gray-400 hover:bg-dark-hover hover:text-gray-200'
}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
title={tf.description}
>
{tf.label}
</button>
))}
</div>
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
import { useState, useEffect } from 'react';
import { TrendingUp, TrendingDown, RotateCcw, Sparkles } from 'lucide-react';
interface TradeControlsProps {
currentPrice: number;
cash: number;
onBuy: (quantity: number) => void;
onSell: (quantity: number) => void;
onReset: () => void;
onAIAnalysis: () => void;
hasPosition: boolean;
isAnalyzing?: boolean;
}
export default function TradeControls({
currentPrice,
cash,
onBuy,
onSell,
onReset,
onAIAnalysis,
hasPosition,
isAnalyzing = false,
}: TradeControlsProps) {
const [quantity, setQuantity] = useState<string>('1');
const [usdAmount, setUsdAmount] = useState<string>('');
// Initialize USD amount when currentPrice changes
useEffect(() => {
const qty = parseFloat(quantity);
if (!isNaN(qty) && qty > 0 && currentPrice > 0) {
setUsdAmount((qty * currentPrice).toFixed(2));
}
}, [currentPrice]);
const handleQuantityChange = (value: string) => {
// Allow empty string, numbers, and decimal points
if (value === '' || /^\d*\.?\d*$/.test(value)) {
setQuantity(value);
const num = parseFloat(value);
if (!isNaN(num) && num > 0) {
setUsdAmount((num * currentPrice).toFixed(2));
} else {
setUsdAmount('');
}
}
};
const handleUsdChange = (value: string) => {
// Allow empty string, numbers, and decimal points
if (value === '' || /^\d*\.?\d*$/.test(value)) {
setUsdAmount(value);
const num = parseFloat(value);
if (!isNaN(num) && num > 0 && currentPrice > 0) {
setQuantity((num / currentPrice).toFixed(4));
} else {
setQuantity('');
}
}
};
const handleBuy = () => {
const qty = parseFloat(quantity);
if (isNaN(qty) || qty <= 0) {
alert('Please enter a valid quantity greater than 0');
return;
}
const cost = qty * currentPrice;
if (cost > cash) {
alert(`Insufficient funds! You need $${cost.toFixed(2)} but only have $${cash.toFixed(2)}`);
return;
}
onBuy(qty);
setQuantity('1');
setUsdAmount((1 * currentPrice).toFixed(2));
};
const handleSell = () => {
const qty = parseFloat(quantity);
if (isNaN(qty) || qty <= 0) {
alert('Please enter a valid quantity greater than 0');
return;
}
if (!hasPosition) {
alert('You have no position to sell!');
return;
}
onSell(qty);
setQuantity('1');
setUsdAmount((1 * currentPrice).toFixed(2));
};
const maxBuyQuantity = currentPrice > 0 ? cash / currentPrice : 0;
const qty = parseFloat(quantity);
const totalCost = !isNaN(qty) && qty > 0 ? qty * currentPrice : 0;
const canBuy = !isNaN(qty) && qty > 0 && totalCost <= cash && currentPrice > 0;
return (
<div className="card">
<h3 className="text-lg font-semibold mb-4">Trade Controls</h3>
<div className="space-y-4">
<div>
<label className="block text-sm text-gray-400 mb-2">Current Price</label>
<div className="bg-dark-bg rounded-lg p-3 text-xl font-bold text-gold-500">
${currentPrice.toFixed(2)}
</div>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">Quantity (oz)</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={quantity}
onChange={(e) => handleQuantityChange(e.target.value)}
className="input flex-1"
placeholder="0.00"
/>
<button
onClick={() => handleQuantityChange(maxBuyQuantity.toFixed(4))}
className="px-3 py-2 bg-blue-600 hover:bg-blue-700 rounded-md text-sm font-medium"
disabled={maxBuyQuantity <= 0}
>
Max
</button>
</div>
<div className="flex gap-1 mb-1">
<button
onClick={() => handleUsdChange((cash * 0.25).toFixed(2))}
className="text-xs px-2 py-1 bg-dark-bg hover:bg-dark-hover rounded"
>
25%
</button>
<button
onClick={() => handleUsdChange((cash * 0.5).toFixed(2))}
className="text-xs px-2 py-1 bg-dark-bg hover:bg-dark-hover rounded"
>
50%
</button>
<button
onClick={() => handleUsdChange((cash * 0.75).toFixed(2))}
className="text-xs px-2 py-1 bg-dark-bg hover:bg-dark-hover rounded"
>
75%
</button>
</div>
<p className="text-xs text-gray-500">
Max: {maxBuyQuantity.toFixed(4)} oz
</p>
</div>
<div>
<label className="block text-sm text-gray-400 mb-2">USD Amount</label>
<input
type="number"
value={usdAmount}
onChange={(e) => handleUsdChange(e.target.value)}
className="input w-full"
min="0"
step="0.01"
placeholder="0.00"
/>
<p className="text-xs text-gray-500 mt-1">
Total: ${totalCost.toFixed(2)}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={handleBuy}
disabled={!canBuy}
className="btn-success flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
title={!canBuy ? (cash === 0 ? 'No funds available' : 'Invalid quantity or insufficient funds') : 'Buy gold (Ctrl+B)'}
>
<TrendingUp className="w-4 h-4" />
Buy
</button>
<button
onClick={handleSell}
disabled={!hasPosition || isNaN(qty) || qty <= 0}
className="btn-danger flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
title={!hasPosition ? 'No position to sell' : 'Sell gold (Ctrl+S)'}
>
<TrendingDown className="w-4 h-4" />
Sell
</button>
</div>
<div className="border-t border-dark-border pt-4 space-y-3">
<button
onClick={onAIAnalysis}
disabled={isAnalyzing}
className="btn-primary w-full flex items-center justify-center gap-2 disabled:opacity-50"
>
<Sparkles className="w-4 h-4" />
{isAnalyzing ? 'Analyzing...' : 'AI Analysis'}
</button>
<button
onClick={onReset}
className="btn w-full bg-gray-700 hover:bg-gray-600 text-white flex items-center justify-center gap-2"
>
<RotateCcw className="w-4 h-4" />
Reset Simulation
</button>
</div>
</div>
</div>
);
}
+458
View File
@@ -0,0 +1,458 @@
import { useState, useEffect } from 'react';
import { BookOpen, Plus, Calendar, TrendingUp, TrendingDown, Smile, Meh, Frown, Search, Filter } from 'lucide-react';
interface JournalEntry {
id: string;
date: string;
timestamp: number;
tradeId?: string;
action: 'BUY' | 'SELL';
price: number;
quantity: number;
pnl?: number;
setupQuality: 1 | 2 | 3 | 4 | 5;
emotionalState: 'confident' | 'neutral' | 'anxious' | 'fearful' | 'greedy';
planFollowed: boolean;
entryReason: string;
exitReason?: string;
marketConditions: string;
lessonsLearned: string;
tags: string[];
}
interface TradingJournalProps {
onAddEntry?: (entry: JournalEntry) => void;
}
export default function TradingJournal({ onAddEntry }: TradingJournalProps) {
const [entries, setEntries] = useState<JournalEntry[]>(() => {
const stored = localStorage.getItem('trading-journal');
if (stored) {
try {
return JSON.parse(stored);
} catch {
return [];
}
}
return [];
});
const [isAddingEntry, setIsAddingEntry] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [filterEmotion, setFilterEmotion] = useState<string>('all');
const [filterPnL, setFilterPnL] = useState<'all' | 'winners' | 'losers'>('all');
const [newEntry, setNewEntry] = useState<Partial<JournalEntry>>({
action: 'BUY',
price: 0,
quantity: 0,
setupQuality: 3,
emotionalState: 'neutral',
planFollowed: true,
entryReason: '',
marketConditions: '',
lessonsLearned: '',
tags: [],
});
useEffect(() => {
localStorage.setItem('trading-journal', JSON.stringify(entries));
}, [entries]);
const handleAddEntry = () => {
const entry: JournalEntry = {
id: Date.now().toString(),
date: new Date().toDateString(),
timestamp: Date.now(),
...newEntry as any,
};
setEntries(prev => [entry, ...prev]);
if (onAddEntry) {
onAddEntry(entry);
}
// Reset form
setNewEntry({
action: 'BUY',
price: 0,
quantity: 0,
setupQuality: 3,
emotionalState: 'neutral',
planFollowed: true,
entryReason: '',
marketConditions: '',
lessonsLearned: '',
tags: [],
});
setIsAddingEntry(false);
};
const filteredEntries = entries.filter(entry => {
// Search filter
if (searchTerm) {
const searchLower = searchTerm.toLowerCase();
const matchesSearch =
entry.entryReason.toLowerCase().includes(searchLower) ||
entry.exitReason?.toLowerCase().includes(searchLower) ||
entry.marketConditions.toLowerCase().includes(searchLower) ||
entry.lessonsLearned.toLowerCase().includes(searchLower) ||
entry.tags.some(tag => tag.toLowerCase().includes(searchLower));
if (!matchesSearch) return false;
}
// Emotion filter
if (filterEmotion !== 'all' && entry.emotionalState !== filterEmotion) {
return false;
}
// P&L filter
if (filterPnL === 'winners' && (!entry.pnl || entry.pnl <= 0)) {
return false;
}
if (filterPnL === 'losers' && (!entry.pnl || entry.pnl >= 0)) {
return false;
}
return true;
});
const getEmotionIcon = (emotion: string) => {
switch (emotion) {
case 'confident':
case 'greedy':
return <Smile className="w-5 h-5 text-green-500" />;
case 'neutral':
return <Meh className="w-5 h-5 text-gray-400" />;
case 'anxious':
case 'fearful':
return <Frown className="w-5 h-5 text-red-500" />;
default:
return <Meh className="w-5 h-5 text-gray-400" />;
}
};
const stats = {
totalEntries: entries.length,
avgSetupQuality: entries.length > 0
? (entries.reduce((sum, e) => sum + e.setupQuality, 0) / entries.length).toFixed(1)
: '0',
planFollowedPercent: entries.length > 0
? ((entries.filter(e => e.planFollowed).length / entries.length) * 100).toFixed(0)
: '0',
};
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="mb-4 pb-4 border-b border-gray-700">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<BookOpen className="w-6 h-6 text-gold-500" />
<div>
<h3 className="text-lg font-semibold">Trading Journal</h3>
<p className="text-xs text-gray-400">{stats.totalEntries} entries recorded</p>
</div>
</div>
<button
onClick={() => setIsAddingEntry(!isAddingEntry)}
className="btn-primary text-sm flex items-center gap-2"
>
<Plus className="w-4 h-4" />
New Entry
</button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-3 gap-3">
<div className="p-2 bg-dark-hover rounded text-center">
<div className="text-xs text-gray-400">Entries</div>
<div className="text-lg font-bold text-gold-500">{stats.totalEntries}</div>
</div>
<div className="p-2 bg-dark-hover rounded text-center">
<div className="text-xs text-gray-400">Avg Setup</div>
<div className="text-lg font-bold text-blue-500">{stats.avgSetupQuality}/5</div>
</div>
<div className="p-2 bg-dark-hover rounded text-center">
<div className="text-xs text-gray-400">Plan Followed</div>
<div className="text-lg font-bold text-green-500">{stats.planFollowedPercent}%</div>
</div>
</div>
</div>
{/* Add Entry Form */}
{isAddingEntry && (
<div className="mb-4 p-4 bg-dark-hover rounded-lg border border-gray-700">
<h4 className="font-semibold mb-3">New Journal Entry</h4>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="block text-xs text-gray-400 mb-1">Action</label>
<select
value={newEntry.action}
onChange={(e) => setNewEntry({ ...newEntry, action: e.target.value as any })}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded text-sm"
>
<option value="BUY">BUY</option>
<option value="SELL">SELL</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Price</label>
<input
type="number"
step="0.01"
value={newEntry.price}
onChange={(e) => setNewEntry({ ...newEntry, price: Number(e.target.value) })}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="block text-xs text-gray-400 mb-1">Setup Quality (1-5)</label>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map(rating => (
<button
key={rating}
onClick={() => setNewEntry({ ...newEntry, setupQuality: rating as any })}
className={`flex-1 py-2 rounded border-2 transition-colors ${
(newEntry.setupQuality || 0) >= rating
? 'border-gold-500 bg-gold-500/20 text-gold-500'
: 'border-gray-700 text-gray-500'
}`}
>
</button>
))}
</div>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1">Emotional State</label>
<select
value={newEntry.emotionalState}
onChange={(e) => setNewEntry({ ...newEntry, emotionalState: e.target.value as any })}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded text-sm"
>
<option value="confident">Confident</option>
<option value="neutral">Neutral</option>
<option value="anxious">Anxious</option>
<option value="fearful">Fearful</option>
<option value="greedy">Greedy</option>
</select>
</div>
</div>
<div className="mb-3">
<label className="block text-xs text-gray-400 mb-1">Entry Reason</label>
<textarea
value={newEntry.entryReason}
onChange={(e) => setNewEntry({ ...newEntry, entryReason: e.target.value })}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded text-sm"
rows={2}
placeholder="Why did you enter this trade?"
/>
</div>
<div className="mb-3">
<label className="block text-xs text-gray-400 mb-1">Market Conditions</label>
<textarea
value={newEntry.marketConditions}
onChange={(e) => setNewEntry({ ...newEntry, marketConditions: e.target.value })}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded text-sm"
rows={2}
placeholder="Describe market conditions..."
/>
</div>
<div className="mb-3">
<label className="block text-xs text-gray-400 mb-1">Lessons Learned</label>
<textarea
value={newEntry.lessonsLearned}
onChange={(e) => setNewEntry({ ...newEntry, lessonsLearned: e.target.value })}
className="w-full px-3 py-2 bg-dark-bg border border-gray-700 rounded text-sm"
rows={2}
placeholder="What did you learn?"
/>
</div>
<div className="mb-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={newEntry.planFollowed}
onChange={(e) => setNewEntry({ ...newEntry, planFollowed: e.target.checked })}
className="rounded"
/>
<span>I followed my trading plan</span>
</label>
</div>
<div className="flex gap-2">
<button onClick={handleAddEntry} className="btn-primary text-sm flex-1">
Save Entry
</button>
<button
onClick={() => setIsAddingEntry(false)}
className="btn-secondary text-sm"
>
Cancel
</button>
</div>
</div>
)}
{/* Filters */}
<div className="mb-4 space-y-2">
<div className="flex gap-2">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search entries..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-3 py-2 bg-dark-bg border border-gray-700 rounded-lg text-sm"
/>
</div>
<button className="p-2 bg-dark-bg border border-gray-700 rounded-lg hover:bg-dark-hover">
<Filter className="w-4 h-4" />
</button>
</div>
<div className="flex gap-2">
<select
value={filterEmotion}
onChange={(e) => setFilterEmotion(e.target.value)}
className="flex-1 px-3 py-2 bg-dark-bg border border-gray-700 rounded text-sm"
>
<option value="all">All Emotions</option>
<option value="confident">Confident</option>
<option value="neutral">Neutral</option>
<option value="anxious">Anxious</option>
<option value="fearful">Fearful</option>
<option value="greedy">Greedy</option>
</select>
<select
value={filterPnL}
onChange={(e) => setFilterPnL(e.target.value as any)}
className="flex-1 px-3 py-2 bg-dark-bg border border-gray-700 rounded text-sm"
>
<option value="all">All Trades</option>
<option value="winners">Winners Only</option>
<option value="losers">Losers Only</option>
</select>
</div>
</div>
{/* Journal Entries */}
<div className="flex-1 overflow-y-auto space-y-3">
{filteredEntries.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<BookOpen className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No journal entries found</p>
<p className="text-sm">Start documenting your trading journey</p>
</div>
) : (
filteredEntries.map(entry => (
<div
key={entry.id}
className="p-4 bg-dark-hover rounded-lg border border-gray-700 hover:border-gray-600 transition-colors"
>
{/* Entry Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
{entry.action === 'BUY' ? (
<TrendingUp className="w-5 h-5 text-green-500" />
) : (
<TrendingDown className="w-5 h-5 text-red-500" />
)}
<div>
<div className="font-semibold">
{entry.action} @ ${entry.price.toFixed(2)}
</div>
<div className="text-xs text-gray-400 flex items-center gap-2">
<Calendar className="w-3 h-3" />
{new Date(entry.timestamp).toLocaleString()}
</div>
</div>
</div>
<div className="text-right">
{entry.pnl !== undefined && (
<div className={`font-bold ${entry.pnl > 0 ? 'text-green-500' : 'text-red-500'}`}>
{entry.pnl > 0 ? '+' : ''}${entry.pnl.toFixed(2)}
</div>
)}
<div className="flex items-center gap-1 justify-end mt-1">
{[...Array(entry.setupQuality)].map((_, i) => (
<span key={i} className="text-gold-500 text-xs"></span>
))}
</div>
</div>
</div>
{/* Emotional State & Plan Followed */}
<div className="flex items-center gap-4 mb-3 text-sm">
<div className="flex items-center gap-2">
{getEmotionIcon(entry.emotionalState)}
<span className="text-gray-400 capitalize">{entry.emotionalState}</span>
</div>
<div className={`flex items-center gap-1 ${entry.planFollowed ? 'text-green-500' : 'text-red-500'}`}>
{entry.planFollowed ? '✓' : '✗'} Plan {entry.planFollowed ? 'Followed' : 'Deviated'}
</div>
</div>
{/* Entry Details */}
{entry.entryReason && (
<div className="mb-2">
<div className="text-xs text-gray-500 font-medium mb-1">Entry Reason:</div>
<div className="text-sm text-gray-300">{entry.entryReason}</div>
</div>
)}
{entry.exitReason && (
<div className="mb-2">
<div className="text-xs text-gray-500 font-medium mb-1">Exit Reason:</div>
<div className="text-sm text-gray-300">{entry.exitReason}</div>
</div>
)}
{entry.marketConditions && (
<div className="mb-2">
<div className="text-xs text-gray-500 font-medium mb-1">Market Conditions:</div>
<div className="text-sm text-gray-300">{entry.marketConditions}</div>
</div>
)}
{entry.lessonsLearned && (
<div className="mb-2">
<div className="text-xs text-gray-500 font-medium mb-1">Lessons Learned:</div>
<div className="text-sm text-gray-300 italic">{entry.lessonsLearned}</div>
</div>
)}
{/* Tags */}
{entry.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{entry.tags.map((tag, i) => (
<span
key={i}
className="px-2 py-1 bg-blue-500/20 text-blue-500 text-xs rounded"
>
{tag}
</span>
))}
</div>
)}
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import React, { useEffect, useMemo, useState } from 'react'
const DEFAULT_LIST = ['BTCUSDT', 'XAUUSD']
const ALL_SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'XAUUSD']
const LS_KEY = 'watchlist.symbols'
export function useWatchlist() {
const [symbols, setSymbols] = useState<string[]>(() => {
try {
const raw = localStorage.getItem(LS_KEY)
if (raw) return JSON.parse(raw)
} catch {}
return DEFAULT_LIST
})
useEffect(() => {
try { localStorage.setItem(LS_KEY, JSON.stringify(symbols)) } catch {}
}, [symbols])
const add = (s: string) => setSymbols(prev => Array.from(new Set([...prev, s])))
const remove = (s: string) => setSymbols(prev => prev.filter(x => x !== s))
return { symbols, add, remove, setSymbols }
}
export default function WatchlistPanel({ onChange }: { onChange?: (symbols: string[]) => void }) {
const { symbols, add, remove, setSymbols } = useWatchlist()
const [candidate, setCandidate] = useState<string>('BTCUSDT')
useEffect(() => { onChange?.(symbols) }, [symbols])
const available = useMemo(() => ALL_SYMBOLS.filter(s => !symbols.includes(s)), [symbols])
return (
<div className="card">
<div className="title">Watchlist</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<select className="input" value={candidate} onChange={e => setCandidate(e.target.value)}>
{available.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<button className="btn-primary" onClick={() => add(candidate)} disabled={!candidate}>Add</button>
<button className="btn" onClick={() => setSymbols(DEFAULT_LIST)}>Reset</button>
</div>
<ul style={{ display: 'grid', gap: 6 }}>
{symbols.map(s => (
<li key={s} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{s}</span>
<button className="btn-danger" onClick={() => remove(s)}>Remove</button>
</li>
))}
</ul>
</div>
)
}