Initial commit: Gold Trading Simulator with AI-powered analysis
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user