learning_ai_invt_trdg/web/src/components/TradeProfileManager.tsx

1390 lines
88 KiB
TypeScript

import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { aggregateHistoryLedger, buildHistoryLedger } from '../lib/tradeHistoryLedger';
import { publishMarketplacePreset } from '../lib/marketplaceApi';
import { fetchTradeHistory } from '../lib/tradeHistoryApi';
import {
createTradeProfile,
deleteTradeProfile,
fetchCurrentUserProfile,
fetchTradeProfiles,
setTradeProfileActive,
type TradeProfilePayload,
updateTradeProfile
} from '../lib/profileApi';
import {
Trash2, Edit3, X,
Check, Zap, Play,
Save, RotateCcw, Info,
Search, Cpu, TrendingUp, Activity,
Settings, Shield, Plus, DollarSign,
BarChart3, Coins, AlertTriangle,
Layers, Target, Clock, Share2
} from 'lucide-react';
import { useAuth } from './AuthContext';
import type { BotState } from '../hooks/useWebSocket';
import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Input } from './ui/input';
import { Select } from './ui/select';
import { cn } from '../lib/utils';
// ChatControl is now rendered globally in App.tsx
// --- TYPES ---
interface RuleConfig {
ruleId: string;
enabled: boolean;
ruleType?: 'mandatory' | 'voting';
params?: Record<string, any>;
}
interface StrategyConfig {
rules: RuleConfig[];
riskLimits: {
maxDailyLossUsd: number;
dailyProfitTargetUsd?: number;
maxOpenTrades: number;
maxConsecutiveLosses: number;
};
execution: {
orderType: 'market' | 'limit';
cooldownMinutes: number;
minRulePassRatio?: number;
entryMode: 'both' | 'long_only';
};
}
interface Profile {
id: string;
user_id: string;
name: string;
allocated_capital: number;
risk_per_trade_percent: number;
symbols: string;
is_active: boolean;
strategy_config?: StrategyConfig;
created_at?: string;
}
interface User {
user_id: string;
email: string;
}
export interface ProfileTradeStats {
winRate: number;
totalPnl: number;
tradeCount: number;
}
// --- CONSTANTS ---
const AVAILABLE_RULES = [
{ id: 'TrendBiasRule', name: 'Trend Bias', desc: 'EMA50/200 direction check on 4H timeframe', icon: TrendingUp, color: '#3b82f6', defaultParams: { fastPeriod: 50, slowPeriod: 200 } },
{ id: 'MomentumRule', name: 'Momentum', desc: 'RSI overbought/oversold confirmation', icon: Activity, color: '#06b6d4', defaultParams: { rsiPeriod: 14, overbought: 70, oversold: 30 } },
{ id: 'ZoneRule', name: 'Zone Proximity', desc: 'Price relative to EMA value zones', icon: Target, color: '#a855f7', defaultParams: { zonePercent: 1.5 } },
{ id: 'SessionRule', name: 'Session Filter', desc: 'Trade only during major sessions', icon: Clock, color: '#f59e0b', defaultParams: { sessions: 'LDN,NY' } },
{ id: 'EntryTriggerRule', name: 'Entry Trigger', desc: 'Pattern-based precise entry logic', icon: Play, color: '#10b981', defaultParams: { showPatterns: true } },
{ id: 'RiskManagementRule', name: 'Risk Guard', desc: 'ATR-based stop loss & position sizing', icon: Shield, color: '#ef4444', defaultParams: { maxRisk: 2.0 } },
{ id: 'AIAnalysisRule', name: 'AI Sentiment', desc: 'LLM-powered market analysis', icon: Cpu, color: '#8b5cf6', defaultParams: { minConfidence: 70 } }
];
const SESSION_PRESET_OPTIONS = [
{ value: '24/7', label: '24/7 (All Sessions)', hint: 'No session-time restriction' },
{ value: 'LDN,NY', label: 'London + New York', hint: 'High-liquidity overlap' },
{ value: 'TOK,SYD', label: 'Tokyo + Sydney', hint: 'Asia-Pacific market hours' },
{ value: 'LDN', label: 'London Only', hint: 'Only London session' },
{ value: 'NY', label: 'New York Only', hint: 'Only New York session' },
{ value: 'TOK', label: 'Tokyo Only', hint: 'Only Tokyo session' },
{ value: 'SYD', label: 'Sydney Only', hint: 'Only Sydney session' }
] as const;
const SESSION_ALIAS_MAP: Record<string, string> = {
LDN: 'LDN',
LONDON: 'LDN',
NY: 'NY',
NEWYORK: 'NY',
TOK: 'TOK',
TOKYO: 'TOK',
SYD: 'SYD',
SYDNEY: 'SYD',
'24/7': '24/7',
'24X7': '24/7',
'247': '24/7',
ALL: '24/7',
ALLSESSIONS: '24/7'
};
const SESSION_TOKEN_ORDER = ['LDN', 'NY', 'TOK', 'SYD'] as const;
type SessionPresetValue = (typeof SESSION_PRESET_OPTIONS)[number]['value'];
const PARAM_LABELS: Record<string, string> = {
fastPeriod: 'Fast EMA Period',
slowPeriod: 'Slow EMA Period',
rsiPeriod: 'RSI Period',
overbought: 'Overbought Level',
oversold: 'Oversold Level',
zonePercent: 'Zone Width %',
sessions: 'Active Sessions',
showPatterns: 'Show Patterns',
maxRisk: 'Max Risk %',
minConfidence: 'Min Confidence',
};
const labelClass = 'text-[10px] font-semibold text-[var(--muted-foreground)] uppercase tracking-wider';
const helpTextClass = 'text-[10px] text-[var(--muted-foreground)]';
const panelClass = 'rounded-xl border border-[var(--border)] bg-[var(--card-elevated)]';
const subtlePanelClass = 'rounded-xl border border-[var(--border)] bg-[var(--muted)]/35';
const iconButtonClass = 'h-8 w-8 rounded-xl';
const accentTextClass = 'text-[var(--accent)]';
export const normalizeSessionPresetValue = (raw: unknown): string => {
if (raw === undefined || raw === null) return 'LDN,NY';
const tokens = (Array.isArray(raw) ? raw.map((token) => String(token)) : String(raw).split(/[,|]/))
.map((token) => token.trim().toUpperCase().replace(/[\s_-]+/g, ''))
.filter(Boolean);
if (tokens.length === 0) return 'LDN,NY';
const mappedTokens = tokens.map((token) => SESSION_ALIAS_MAP[token] || token);
if (mappedTokens.includes('24/7')) return '24/7';
const normalizedSessionTokens: string[] = [];
mappedTokens.forEach((token) => {
if (SESSION_TOKEN_ORDER.includes(token as any) && !normalizedSessionTokens.includes(token)) {
normalizedSessionTokens.push(token);
}
});
const orderedTokens = SESSION_TOKEN_ORDER.filter((token) => normalizedSessionTokens.includes(token));
if (orderedTokens.length === SESSION_TOKEN_ORDER.length) return '24/7';
if (orderedTokens.length > 0) return orderedTokens.join(',');
return String(raw).trim();
};
export const resolveSessionPresetSelection = (raw: unknown): SessionPresetValue | 'custom' => {
const normalizedValue = normalizeSessionPresetValue(raw);
if (SESSION_PRESET_OPTIONS.some((option) => option.value === normalizedValue)) {
return normalizedValue as SessionPresetValue;
}
return 'custom';
};
export const normalizeStrategyConfig = (rawConfig?: StrategyConfig): StrategyConfig => {
const safeConfig = rawConfig && typeof rawConfig === 'object'
? rawConfig
: {} as StrategyConfig;
const rawRules = Array.isArray(safeConfig.rules) ? safeConfig.rules : [];
const normalizedRules: RuleConfig[] = rawRules
.filter((rule: any) => rule && typeof rule.ruleId === 'string')
.map((rule: any) => {
const safeParams = rule.params && typeof rule.params === 'object' ? { ...rule.params } : {};
if (rule.ruleId === 'SessionRule' && safeParams.allowedSessions && !safeParams.sessions) {
safeParams.sessions = safeParams.allowedSessions;
}
if (rule.ruleId === 'AIAnalysisRule') {
const rawConfidence = Number(safeParams.minConfidence ?? safeParams.confidenceThreshold ?? 70);
safeParams.minConfidence = Number.isFinite(rawConfidence) && rawConfidence >= 0
? (rawConfidence <= 1 ? rawConfidence * 100 : rawConfidence)
: 70;
}
return {
ruleId: String(rule.ruleId),
enabled: Boolean(rule.enabled),
ruleType: (rule.ruleType === 'mandatory' || rule.ruleType === 'voting') ? rule.ruleType : undefined,
params: safeParams
};
});
const rawRisk = safeConfig.riskLimits || {};
const maxDailyLossUsd = Number((rawRisk as any).maxDailyLossUsd);
const dailyProfitTargetUsd = Number((rawRisk as any).dailyProfitTargetUsd);
const maxOpenTrades = Number((rawRisk as any).maxOpenTrades);
const maxConsecutiveLosses = Number((rawRisk as any).maxConsecutiveLosses);
const rawExecution = safeConfig.execution || {};
const rawOrderType = String((rawExecution as any).orderType || 'market').toLowerCase();
const cooldownMinutes = Number((rawExecution as any).cooldownMinutes);
const minRulePassRatio = Number((rawExecution as any).minRulePassRatio);
const rawEntryMode = String(
(rawExecution as any).entryMode ?? ((rawExecution as any).longOnly ? 'long_only' : 'both')
).toLowerCase();
const entryMode: 'both' | 'long_only' =
(rawEntryMode === 'long_only' || rawEntryMode === 'longonly' || rawEntryMode === 'buy_only')
? 'long_only'
: 'both';
return {
rules: normalizedRules,
riskLimits: {
maxDailyLossUsd: Number.isFinite(maxDailyLossUsd) && maxDailyLossUsd > 0 ? maxDailyLossUsd : 50,
dailyProfitTargetUsd: Number.isFinite(dailyProfitTargetUsd) && dailyProfitTargetUsd > 0 ? dailyProfitTargetUsd : undefined,
maxOpenTrades: Number.isFinite(maxOpenTrades) && maxOpenTrades > 0 ? Math.floor(maxOpenTrades) : 3,
maxConsecutiveLosses: Number.isFinite(maxConsecutiveLosses) && maxConsecutiveLosses >= 0 ? Math.floor(maxConsecutiveLosses) : 2,
},
execution: {
orderType: rawOrderType === 'limit' ? 'limit' : 'market',
cooldownMinutes: Number.isFinite(cooldownMinutes) && cooldownMinutes >= 0 ? cooldownMinutes : 30,
minRulePassRatio: Number.isFinite(minRulePassRatio) && minRulePassRatio >= 0 && minRulePassRatio <= 1 ? minRulePassRatio : 1.0,
entryMode
}
};
};
export const filterProfilesBySearch = (profiles: Profile[], searchTerm: string) => {
const term = searchTerm.toLowerCase();
return profiles.filter((profile) => profile.name.toLowerCase().includes(term));
};
export const summarizePortfolioStats = (
profiles: Profile[],
tradeStats: Record<string, ProfileTradeStats>
) => ({
activeCount: profiles.filter((profile) => profile.is_active).length,
totalCapital: profiles.reduce((sum, profile) => sum + (profile.allocated_capital || 0), 0),
totalPnl: Object.values(tradeStats).reduce((sum, stat) => sum + stat.totalPnl, 0),
totalTrades: Object.values(tradeStats).reduce((sum, stat) => sum + stat.tradeCount, 0)
});
// --- UI COMPONENTS ---
const ToggleSwitch = ({ checked, onChange }: { checked: boolean, onChange: (v: boolean) => void }) => (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onChange(!checked); }}
className={cn(
'relative inline-flex h-5 w-9 items-center rounded-full border transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]',
checked ? 'border-[var(--accent)] bg-[var(--accent)]' : 'border-[var(--border)] bg-[var(--muted)]'
)}
>
<span className={`${checked ? 'translate-x-5 bg-white' : 'translate-x-1 bg-[var(--muted-foreground)]'} inline-block h-3 w-3 transform rounded-full transition-transform duration-200`} />
</button>
);
const Slider = ({ value, onChange, min, max, step, unit, label }: { value: number, onChange: (n: number) => void, min: number, max: number, step?: number, unit?: string, label: string }) => (
<div className={cn('space-y-2.5 p-4', subtlePanelClass)}>
<div className="flex justify-between items-center">
<span className={labelClass}>{label}</span>
<span className="rounded-lg bg-[var(--accent-soft)] px-2.5 py-0.5 font-mono text-xs font-bold text-[var(--accent)]">{value}{unit}</span>
</div>
<input
type="range" min={min} max={max} step={step || 1} value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="h-1 w-full accent-[var(--accent)]"
/>
<div className="flex justify-between font-mono text-[9px] text-[var(--muted-foreground)]">
<span>{min}{unit}</span>
<span>{max}{unit}</span>
</div>
</div>
);
const StatPill = ({ icon: Icon, label, value, color }: { icon: any, label: string, value: string, color: string }) => (
<Card className="relative overflow-hidden rounded-xl shadow-none">
<div className="h-[2px] bg-[var(--accent)]/60" />
<div className="flex items-center gap-3.5 px-4 py-3.5">
<div className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--accent-soft)]">
<Icon size={15} style={{ color }} />
</div>
<div>
<p className="mb-0.5 text-[9px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">{label}</p>
<p className="font-mono text-sm font-bold leading-none text-[var(--foreground)]">{value}</p>
</div>
</div>
</Card>
);
interface TradeProfileManagerProps {
botState?: BotState;
}
// --- MAIN COMPONENT ---
export const TradeProfileManager = ({ botState = DEFAULT_BOT_STATE }: TradeProfileManagerProps) => {
const { user: authUser, profile } = useAuth();
const [profiles, setProfiles] = useState<Profile[]>([]);
const [tradeStats, setTradeStats] = useState<Record<string, ProfileTradeStats>>({});
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [currentUserProfile, setCurrentUserProfile] = useState<User | null>(null);
// Drawer state
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [isAddingMode, setIsAddingMode] = useState(false);
const [editingProfile, setEditingProfile] = useState<Partial<Profile>>({});
const [drawerTab, setDrawerTab] = useState<'settings' | 'logic' | 'advanced'>('settings');
// Utilities
const [toasts, setToasts] = useState<{ id: number, msg: string, type: 'success' | 'error' | 'info' }[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const addToast = (msg: string, type: 'success' | 'error' | 'info' = 'info') => {
const id = Date.now();
setToasts(prev => [...prev, { id, msg, type }]);
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 4000);
};
// --- DATA ---
const fetchData = async () => {
setLoading(true);
try {
const [profilesData, meProfile, hRes] = await Promise.all([
fetchTradeProfiles({ scope: profile?.role === 'admin' ? 'all' : 'user' }),
fetchCurrentUserProfile().catch(() => null),
fetchTradeHistory({ scope: profile?.role === 'admin' ? 'all' : 'user' })
]);
const normalizedProfiles = (profilesData || []).map((profile: any) => ({
...profile,
strategy_config: normalizeStrategyConfig(profile.strategy_config as StrategyConfig)
}));
setProfiles(normalizedProfiles);
setCurrentUserProfile(meProfile ? {
user_id: String(meProfile.user_id || authUser?.id || ''),
email: String(meProfile.email || authUser?.email || '')
} : (authUser?.id || authUser?.email ? {
user_id: String(authUser?.id || ''),
email: String(authUser?.email || '')
} : null));
const historyLedger = buildHistoryLedger({
dbRows: hRes || [],
includeRealtime: false
});
const historyAggregate = aggregateHistoryLedger(historyLedger);
const stats: Record<string, any> = {};
normalizedProfiles.forEach((p: any) => {
const profileStats = historyAggregate.byProfile[p.id];
stats[p.id] = {
tradeCount: profileStats?.tradeCount || 0,
totalPnl: profileStats?.realizedPnl || 0,
winRate: profileStats?.winRate || 0
};
});
setTradeStats(stats);
} catch (e) {
console.error('[ProfileManager] Fetch error', e);
addToast('Failed to load profiles', 'error');
}
setLoading(false);
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
// Listen for chat-created profile updates to refresh instantly
const onProfilesUpdated = () => fetchData();
window.addEventListener('profiles-updated', onProfilesUpdated);
return () => {
clearInterval(interval);
window.removeEventListener('profiles-updated', onProfilesUpdated);
};
}, []);
// --- ACTIONS ---
const handleOpenEdit = (profile: Profile) => {
setEditingProfile({
...profile,
strategy_config: normalizeStrategyConfig(profile.strategy_config)
});
setIsAddingMode(false);
setDrawerTab('settings');
setIsDrawerOpen(true);
};
const handleOpenAdd = () => {
setEditingProfile({
name: '',
user_id: authUser?.id || currentUserProfile?.user_id || '',
allocated_capital: 1000,
risk_per_trade_percent: 1,
is_active: true,
symbols: 'BTC/USDT, ETH/USDT',
strategy_config: {
rules: AVAILABLE_RULES.map(r => ({ ruleId: r.id, enabled: true, params: { ...r.defaultParams } })),
riskLimits: { maxDailyLossUsd: 50, dailyProfitTargetUsd: 100, maxOpenTrades: 3, maxConsecutiveLosses: 2 },
execution: { orderType: 'market', cooldownMinutes: 30, minRulePassRatio: 1.0, entryMode: 'both' }
}
});
setIsAddingMode(true);
setDrawerTab('settings');
setIsDrawerOpen(true);
};
const handleSave = async () => {
if (!editingProfile.name) return addToast('Profile name is required', 'error');
setLoading(true);
const id = isAddingMode ? null : editingProfile.id;
const payload: TradeProfilePayload = {
name: editingProfile.name,
user_id: editingProfile.user_id || authUser?.id || currentUserProfile?.user_id,
allocated_capital: Number(editingProfile.allocated_capital),
risk_per_trade_percent: Number(editingProfile.risk_per_trade_percent),
symbols: editingProfile.symbols || '',
is_active: editingProfile.is_active ?? true,
strategy_config: normalizeStrategyConfig(editingProfile.strategy_config as StrategyConfig) as unknown as Record<string, unknown>
};
try {
if (id) {
await updateTradeProfile(id, payload);
} else {
await createTradeProfile(payload);
}
addToast(isAddingMode ? 'Profile created successfully' : 'Profile updated', 'success');
setIsDrawerOpen(false);
await fetchData();
} catch (error: any) {
addToast(error?.message || 'Failed to save profile', 'error');
}
setLoading(false);
};
const handleDelete = async (profileId: string) => {
try {
await deleteTradeProfile(profileId);
addToast('Profile deleted', 'success');
await fetchData();
} catch (error: any) {
addToast(`Delete failed: ${error?.message || 'Unknown error'}`, 'error');
}
setDeleteConfirm(null);
};
const toggleRule = (ruleId: string) => {
const rules = [...(editingProfile.strategy_config?.rules || [])];
const idx = rules.findIndex(r => r.ruleId === ruleId);
if (idx !== -1) {
rules[idx] = { ...rules[idx], enabled: !rules[idx].enabled };
} else {
rules.push({ ruleId, enabled: true, params: {} });
}
setEditingProfile({ ...editingProfile, strategy_config: { ...editingProfile.strategy_config, rules } as StrategyConfig });
};
const updateRuleParam = (ruleId: string, key: string, value: string | number | boolean) => {
const newRules = editingProfile.strategy_config?.rules?.map((rule) =>
rule.ruleId === ruleId ? { ...rule, params: { ...rule.params, [key]: value } } : rule
);
setEditingProfile({
...editingProfile,
strategy_config: { ...editingProfile.strategy_config, rules: newRules } as StrategyConfig
});
};
const toggleProfileActive = async (profile: Profile) => {
const newState = !profile.is_active;
try {
await setTradeProfileActive(profile.id, newState);
addToast(newState ? 'Profile activated' : 'Profile suspended', 'success');
await fetchData();
} catch (error: any) {
addToast(error?.message || 'Failed to update profile state', 'error');
}
};
const handlePublish = async (p: Profile) => {
if (profile?.role !== 'admin') return;
setLoading(true);
// Find risk style based on pass ratio
const passRatio = p.strategy_config?.execution?.minRulePassRatio || 1.0;
const styleId = passRatio < 0.9 ? 'aggressive' : (passRatio >= 1.0 ? 'safe' : 'balanced');
const payload = {
id: `template-${p.id}-${Date.now()}`,
name: p.name,
description: `Admin-published strategy based on ${p.name}. Features ${p.strategy_config?.rules?.filter(r => r.enabled).length} optimized rules.`,
risk_style_id: styleId,
recommended_assets: p.symbols.split(',').map(s => s.trim()),
typical_trades_per_day: styleId === 'aggressive' ? '8-12' : (styleId === 'safe' ? '1-2' : '3-5'),
performance_tag: 'Institutional Template',
is_popular: true,
created_by: authUser?.id,
original_profile_id: p.id,
strategy_config: p.strategy_config || {}
};
try {
await publishMarketplacePreset(payload);
addToast('Strategy published to Marketplace!', 'success');
} catch (error: any) {
addToast(`Publish failed: ${error?.message || 'Unknown error'}`, 'error');
}
setLoading(false);
};
const filtered = filterProfilesBySearch(profiles, searchTerm);
const { activeCount, totalCapital, totalPnl, totalTrades } = summarizePortfolioStats(profiles, tradeStats);
const getUserEmail = (userId: string) => {
if (userId && userId === currentUserProfile?.user_id) {
return currentUserProfile.email;
}
if (userId && userId === authUser?.id) {
return authUser?.email || '';
}
return '';
};
// --- RENDER ---
return (
<div className="space-y-6">
{/* ─── PAGE HEADER ─── */}
<div className="flex flex-col gap-5">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--accent-soft)]">
<Layers size={15} className={accentTextClass} />
</div>
<h1 className="text-lg font-bold text-[var(--foreground)]">Strategy Clusters</h1>
{loading && <RotateCcw size={14} className="animate-spin text-[var(--accent)]/60" />}
</div>
<p className="ml-11 text-[11px] text-[var(--muted-foreground)]">Manage trading profiles, rules, and capital allocation</p>
</div>
<div className="flex items-center gap-2.5">
<div className="relative">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]" />
<Input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="h-10 w-44 pl-8 pr-3 text-[11px]"
/>
</div>
<Button
onClick={fetchData}
title="Refresh"
variant="outline"
size="icon"
className="h-10 w-10"
>
<RotateCcw size={13} />
</Button>
<Button
onClick={handleOpenAdd}
size="sm"
className="px-5"
>
<Plus size={13} strokeWidth={2.5} />
<span>New Profile</span>
</Button>
</div>
</div>
{/* Aggregate stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatPill icon={Layers} label="Total Profiles" value={`${activeCount} / ${profiles.length}`} color="#00ff88" />
<StatPill icon={DollarSign} label="Total Capital" value={`$${totalCapital.toLocaleString()}`} color="#3b82f6" />
<StatPill icon={BarChart3} label="Realized P&L" value={`${totalPnl >= 0 ? '+' : ''}$${totalPnl.toFixed(2)}`} color={totalPnl >= 0 ? '#00ff88' : '#ef4444'} />
<StatPill icon={Activity} label="Total Trades" value={`${totalTrades}`} color="#a855f7" />
</div>
</div>
{/* ─── EMPTY STATE ─── */}
{filtered.length === 0 && !loading && (
<Card className="flex flex-col items-center justify-center rounded-2xl border-dashed py-20 shadow-none">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-[var(--muted)]">
<Coins size={24} className="text-[var(--muted-foreground)]" />
</div>
<p className="mb-1 text-sm font-bold text-[var(--foreground)]">
{searchTerm ? 'No matching profiles' : 'No profiles yet'}
</p>
<p className="mb-5 text-[11px] text-[var(--muted-foreground)]">
{searchTerm ? 'Try a different search term' : 'Create your first strategy profile to start trading'}
</p>
{!searchTerm && (
<Button
onClick={handleOpenAdd}
size="sm"
>
<Plus size={14} />
Create Profile
</Button>
)}
</Card>
)}
{/* ─── PROFILE CARDS ─── */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{filtered.map(p => {
const stats = tradeStats[p.id] || { winRate: 0, totalPnl: 0, tradeCount: 0 };
const enabledRules = p.strategy_config?.rules?.filter(r => r.enabled) || [];
const ruleCount = enabledRules.length;
const email = getUserEmail(p.user_id);
const maxLoss = p.strategy_config?.riskLimits?.maxDailyLossUsd ?? 50;
const profitTarget = p.strategy_config?.riskLimits?.dailyProfitTargetUsd;
const maxOpen = p.strategy_config?.riskLimits?.maxOpenTrades ?? 3;
const orderType = p.strategy_config?.execution?.orderType ?? 'market';
const cooldown = p.strategy_config?.execution?.cooldownMinutes ?? 30;
const minPassRatio = p.strategy_config?.execution?.minRulePassRatio ?? 1.0;
const entryMode = p.strategy_config?.execution?.entryMode ?? 'both';
const usedNotional = botState.positions
.filter(pos => pos.profileId === p.id)
.reduce((sum, pos) => sum + Math.abs((pos.entryPrice || 0) * (pos.size || 0)), 0);
const capitalHealth = usedNotional > (p.allocated_capital || 0) ? 'Issue' : 'OK';
// --- NEW: Capital Coverage Check (Allocated vs Buying Power) ---
// This is observational only.
const bp = botState.accountSnapshot?.buying_power ?? 0;
const isCovered = (p.allocated_capital || 0) <= bp;
const coverageStatus = isCovered ? 'Covered' : 'Insufficient funds';
const coverageColor = isCovered ? 'ok' : 'drift'; // reuse 'drift' style for warning
const lifecycleStatus = stats.tradeCount > 0 ? 'OK' : (p.is_active ? 'Blocked' : 'Idle');
const reconciliationDrift = (botState.health?.reconciliationMismatchCount || 0) > 0 ? 'Drift' : 'Clean';
const lockStatus = (botState.health?.lockContentionCount || 0) > 0 ? 'Contended' : 'Stable';
return (
<Card
key={p.id}
className={`group relative overflow-hidden rounded-2xl transition-all duration-200 ${p.is_active
? 'hover:translate-y-[-2px]'
: 'opacity-55'
}`}
>
{/* Top accent bar */}
<div className={`h-[3px] ${p.is_active
? 'bg-[var(--accent)]'
: 'bg-[var(--muted)]'
}`}
/>
{/* Card header area */}
<div className="px-5 pt-5 pb-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="mb-2 truncate text-[15px] font-bold text-[var(--foreground)]">{p.name}</h3>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[9px] font-bold uppercase tracking-wider border ${p.is_active
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-[var(--border)] bg-[var(--muted)] text-[var(--muted-foreground)]'
}`}>
<span className={`h-1.5 w-1.5 rounded-full ${p.is_active ? 'animate-pulse bg-emerald-500' : 'bg-[var(--muted-foreground)]'}`} />
{p.is_active ? 'Active' : 'Paused'}
</span>
{email && (
<span className="max-w-[160px] truncate text-[10px] text-[var(--muted-foreground)]">{email}</span>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1.5 shrink-0">
<Button
onClick={(e) => { e.stopPropagation(); toggleProfileActive(p); }}
title={p.is_active ? 'Pause profile' : 'Activate profile'}
variant={p.is_active ? 'default' : 'outline'}
size="icon"
className={iconButtonClass}
>
<Zap size={14} fill={p.is_active ? 'currentColor' : 'none'} />
</Button>
<Button
onClick={(e) => { e.stopPropagation(); handleOpenEdit(p); }}
title="Edit profile"
variant="outline"
size="icon"
className={iconButtonClass}
>
<Edit3 size={14} />
</Button>
<Button
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(p.id); }}
title="Delete profile"
variant="outline"
size="icon"
className={cn(iconButtonClass, 'hover:text-[var(--destructive)]')}
>
<Trash2 size={14} />
</Button>
{profile?.role === 'admin' && (
<Button
onClick={(e) => { e.stopPropagation(); handlePublish(p); }}
title="Publish to Marketplace"
variant="outline"
size="icon"
className={iconButtonClass}
>
<Share2 size={14} />
</Button>
)}
</div>
</div>
</div>
{/* ── Elevated inner panel: Key Metrics ── */}
<div className={cn('mx-4 mb-4 overflow-hidden', panelClass)}>
{/* Stats header row */}
<div className="grid grid-cols-3">
{[
{ icon: DollarSign, label: 'Capital', val: `$${p.allocated_capital.toLocaleString()}`, color: '#3b82f6', valClass: 'text-[var(--foreground)]' },
{ icon: BarChart3, label: 'Realized', val: `${stats.totalPnl >= 0 ? '+' : ''}${stats.totalPnl.toFixed(2)}`, color: stats.totalPnl >= 0 ? '#16a34a' : '#dc2626', valClass: stats.totalPnl >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400' },
{ icon: AlertTriangle, label: 'Risk', val: `${p.risk_per_trade_percent}%`, color: '#f59e0b', valClass: 'text-[var(--foreground)]' },
].map((item, i) => (
<div key={item.label} className={cn('relative px-4 py-4 text-center', i < 2 && 'border-r border-[var(--border)]')}>
<div className="relative">
<div className="flex items-center justify-center gap-1.5 mb-1.5">
<div className="flex h-4 w-4 items-center justify-center rounded" style={{
background: `${item.color}15`,
}}>
<item.icon size={9} style={{ color: item.color }} />
</div>
<span className="text-[8px] font-bold uppercase tracking-widest text-[var(--muted-foreground)]">{item.label}</span>
</div>
<span className={cn('font-mono text-[14px] font-bold', item.valClass)}>{item.val}</span>
</div>
</div>
))}
</div>
<div className="profile-health-strip flex flex-wrap gap-1 px-4 py-2">
<span className={`health-pill ${capitalHealth === 'Issue' ? 'issue' : 'ok'}`}>Capital: {capitalHealth}</span>
{botState.accountSnapshot && (
<span className={`health-pill ${coverageColor}`} title={`Buying Power: $${bp.toFixed(2)}`}>
LP: {coverageStatus}
</span>
)}
<span className={`health-pill ${lifecycleStatus === 'OK' ? 'ok' : 'blocked'}`}>Lifecycle: {lifecycleStatus}</span>
<span className={`health-pill ${reconciliationDrift === 'Drift' ? 'drift' : 'clean'}`}>Reconciliation: {reconciliationDrift}</span>
<span className={`health-pill ${lockStatus === 'Contended' ? 'drift' : 'ok'}`}>Locks: {lockStatus}</span>
</div>
{/* Win rate section */}
<div className="border-t border-[var(--border)] bg-[var(--muted)]/25 px-4 py-3">
<div className="flex justify-between items-center mb-2">
<span className="text-[9px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">Win Rate</span>
<span className="font-mono text-[10px] font-bold text-[var(--muted-foreground)]">{stats.winRate.toFixed(1)}% · {stats.tradeCount} trades</span>
</div>
<div className="h-[7px] w-full overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full rounded-full transition-all duration-1000"
style={{
width: `${Math.max(stats.winRate, 2)}%`,
background: stats.winRate >= 50
? '#16a34a'
: stats.winRate >= 30
? '#f59e0b'
: '#dc2626',
}}
/>
</div>
</div>
</div>
{/* ── Elevated inner panel: Details Table ── */}
<div className={cn('mx-4 mb-4 overflow-hidden', panelClass)}>
{/* Table header */}
<div className="flex items-center gap-2 border-b border-[var(--border)] bg-[var(--muted)]/25 px-4 py-2">
<Settings size={9} className="text-[var(--muted-foreground)]" />
<span className="text-[8px] font-bold uppercase tracking-widest text-[var(--muted-foreground)]">Configuration</span>
</div>
<table className="w-full text-left">
<tbody>
<tr className="border-t border-transparent even:bg-[var(--muted)]/20">
<td className="px-4 py-2.5 text-[10px] font-medium text-[var(--muted-foreground)]">Rules Active</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1.5">
{enabledRules.slice(0, 5).map(r => {
const ruleDef = AVAILABLE_RULES.find(ar => ar.id === r.ruleId);
return ruleDef ? (
<span key={r.ruleId} className="w-6 h-6 rounded-md flex items-center justify-center" style={{
background: `${ruleDef.color}12`,
border: `1px solid ${ruleDef.color}30`,
boxShadow: `0 1px 4px ${ruleDef.color}10`,
}} title={ruleDef.name}>
<ruleDef.icon size={11} style={{ color: ruleDef.color }} />
</span>
) : null;
})}
{ruleCount > 5 && (
<span className="ml-0.5 font-mono text-[9px] font-bold text-[var(--muted-foreground)]">+{ruleCount - 5}</span>
)}
</div>
</td>
</tr>
<tr className="border-t border-[var(--border)] even:bg-[var(--muted)]/20">
<td className="px-4 py-2.5 text-[10px] font-medium text-[var(--muted-foreground)]">Max Daily Loss</td>
<td className="px-4 py-2.5 text-right">
<span className="rounded-md border border-red-500/20 bg-red-500/10 px-2 py-0.5 font-mono text-[11px] font-bold text-red-600 dark:text-red-400">${maxLoss}</span>
</td>
</tr>
<tr className="border-t border-[var(--border)] even:bg-[var(--muted)]/20">
<td className="px-4 py-2.5 text-[10px] font-medium text-[var(--muted-foreground)]">Max Open Trades</td>
<td className="px-4 py-2.5 text-right">
<span className="rounded-md border border-violet-500/20 bg-violet-500/10 px-2 py-0.5 font-mono text-[11px] font-bold text-violet-600 dark:text-violet-400">{maxOpen}</span>
</td>
</tr>
<tr className="border-t border-[var(--border)] even:bg-[var(--muted)]/20">
<td className="px-4 py-2.5 text-[10px] font-medium text-[var(--muted-foreground)]">Order Type</td>
<td className="px-4 py-2.5 text-right">
<span className={cn(
'rounded-md border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider',
orderType === 'market'
? 'border-cyan-500/20 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300'
: 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300'
)}>
{orderType}
</span>
</td>
</tr>
<tr className="border-t border-[var(--border)] even:bg-[var(--muted)]/20">
<td className="px-4 py-2.5 text-[10px] font-medium text-[var(--muted-foreground)]">Cooldown</td>
<td className="px-4 py-2.5 text-right">
<span className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 font-mono text-[11px] font-bold text-blue-700 dark:text-blue-300">{cooldown}m</span>
</td>
</tr>
<tr className="border-t border-[var(--border)] even:bg-[var(--muted)]/20">
<td className="px-4 py-2.5 text-[10px] font-medium text-[var(--muted-foreground)]">Entry Mode</td>
<td className="px-4 py-2.5 text-right">
<span className={cn(
'rounded-md border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider',
entryMode === 'long_only'
? 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
)}>
{entryMode === 'long_only' ? 'LONG ONLY' : 'BOTH SIDES'}
</span>
</td>
</tr>
{profitTarget && (
<tr className="border-t border-[var(--border)] even:bg-[var(--muted)]/20">
<td className="px-4 py-2.5 text-[10px] font-medium text-[var(--muted-foreground)]">Profit Target</td>
<td className="px-4 py-2.5 text-right">
<span className="rounded-md border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5 font-mono text-[11px] font-bold text-emerald-700 dark:text-emerald-300">${profitTarget}</span>
</td>
</tr>
)}
<tr className="border-t border-[var(--border)] even:bg-[var(--muted)]/20">
<td className="px-4 py-2.5 text-[10px] font-medium text-[var(--muted-foreground)]">Voting Threshold</td>
<td className="px-4 py-2.5 text-right">
<span className={`rounded-md border px-2 py-0.5 font-mono text-[11px] font-bold ${minPassRatio < 1.0 ? 'border-amber-500/20 bg-amber-500/10 text-amber-700 dark:text-amber-300' : 'border-[var(--border)] bg-[var(--muted)] text-[var(--muted-foreground)]'}`}>{(minPassRatio * 100).toFixed(0)}%</span>
</td>
</tr>
</tbody>
</table>
</div>
{/* ── Bottom: Symbol tags ── */}
<div className="px-5 pb-5">
<div className="flex flex-wrap gap-1.5">
{p.symbols.split(',').slice(0, 5).map(s => (
<span key={s} className="rounded-lg border border-[var(--border)] bg-[var(--muted)]/35 px-2.5 py-1 font-mono text-[10px] font-medium text-[var(--muted-foreground)]">
{s.trim()}
</span>
))}
{p.symbols.split(',').length > 5 && (
<span className="rounded-lg border border-[var(--border)] bg-[var(--muted)]/20 px-2.5 py-1 font-mono text-[10px] text-[var(--muted-foreground)]">
+{p.symbols.split(',').length - 5}
</span>
)}
</div>
</div>
</Card>
);
})}
</div>
{/* ─── EDIT / CREATE DRAWER ─── */}
{isDrawerOpen && createPortal(
<div className="fixed inset-0 z-[999999] flex justify-end">
<div
className="absolute inset-0 bg-slate-950/70 backdrop-blur-sm"
onClick={() => setIsDrawerOpen(false)}
/>
<div
className="relative flex h-full w-[520px] max-w-[92vw] flex-col border-l border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] shadow-[var(--card-shadow)]"
style={{ animation: 'profileDrawerSlide 0.2s ease-out' }}
>
{/* Drawer header */}
<div className="border-b border-[var(--border)] px-6 py-5">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`flex h-9 w-9 items-center justify-center rounded-xl border ${isAddingMode
? 'border-[var(--border)] bg-[var(--accent-soft)]'
: 'border-blue-500/20 bg-blue-500/10'
}`}>
{isAddingMode
? <Plus size={16} className={accentTextClass} />
: <Edit3 size={16} className="text-blue-400" />
}
</div>
<div>
<h2 className="text-base font-bold text-[var(--foreground)]">{isAddingMode ? 'Create Profile' : 'Edit Profile'}</h2>
<p className="mt-0.5 text-[10px] text-[var(--muted-foreground)]">{isAddingMode ? 'Configure a new trading strategy' : editingProfile.name}</p>
</div>
</div>
<Button onClick={() => setIsDrawerOpen(false)} variant="ghost" size="icon" className="h-8 w-8 rounded-lg">
<X size={15} />
</Button>
</div>
{/* Drawer tab nav */}
<div className="flex rounded-lg border border-[var(--border)] bg-[var(--muted)] p-0.5">
{([
{ id: 'settings' as const, label: 'General', icon: Settings },
{ id: 'logic' as const, label: 'Rules', icon: Cpu },
{ id: 'advanced' as const, label: 'Risk & Execution', icon: Shield },
]).map(t => (
<button
key={t.id}
onClick={() => setDrawerTab(t.id)}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-md text-[10px] font-bold uppercase tracking-wider transition-all ${drawerTab === t.id
? 'bg-[var(--card)] text-[var(--foreground)] shadow-sm'
: 'text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
<t.icon size={11} />
{t.label}
</button>
))}
</div>
</div>
{/* Drawer content */}
<div className="flex-1 space-y-5 overflow-y-auto px-6 py-5" style={{ scrollbarWidth: 'thin' }}>
{drawerTab === 'settings' && (
<>
{/* Profile Name */}
<div className="space-y-1.5">
<label className={labelClass}>Profile Name</label>
<Input
type="text"
value={editingProfile.name || ''}
onChange={e => setEditingProfile({ ...editingProfile, name: e.target.value })}
placeholder="e.g. BTC Momentum Scalper"
/>
</div>
{/* Current User (read-only) */}
<div className="space-y-1.5">
<label className={labelClass}>User</label>
<div className={cn('flex w-full items-center gap-2 px-4 py-2.5', panelClass)}>
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-[var(--accent-soft)] text-[9px] font-bold text-[var(--accent)]">
{(authUser?.email || '?').charAt(0).toUpperCase()}
</div>
<span className="text-[13px] text-[var(--foreground)]">{authUser?.email || 'Current User'}</span>
<span className="ml-auto rounded-md bg-[var(--accent-soft)] px-2 py-0.5 text-[9px] font-bold text-[var(--accent)]">You</span>
</div>
</div>
{/* Trading Symbols */}
<div className="space-y-1.5">
<label className={labelClass}>Trading Symbols</label>
<textarea
value={editingProfile.symbols || ''}
onChange={e => setEditingProfile({ ...editingProfile, symbols: e.target.value })}
className="h-20 w-full resize-none rounded-xl border border-[var(--border)] bg-[var(--input)] px-4 py-2.5 font-mono text-sm text-[var(--foreground)] outline-none transition placeholder:text-[var(--muted-foreground)] focus:border-[var(--ring)] focus:ring-2 focus:ring-[var(--ring-soft)]"
placeholder="BTC/USDT, ETH/USDT, SOL/USDT"
/>
<p className="text-[9px] text-[var(--muted-foreground)]">Comma-separated trading pairs</p>
</div>
{/* Active Toggle */}
<div className={cn('flex items-center justify-between p-4', subtlePanelClass)}>
<div className="flex items-center gap-3">
<Zap size={14} className={editingProfile.is_active ? accentTextClass : 'text-[var(--muted-foreground)]'} />
<div>
<p className="text-xs font-semibold text-[var(--foreground)]">Profile Active</p>
<p className={helpTextClass}>Enable auto-trading for this profile</p>
</div>
</div>
<ToggleSwitch
checked={editingProfile.is_active ?? true}
onChange={v => setEditingProfile({ ...editingProfile, is_active: v })}
/>
</div>
</>
)}
{drawerTab === 'logic' && (
<div className="space-y-2.5">
<div className="flex items-center justify-between mb-1">
<p className={helpTextClass}>Toggle rules on/off and configure parameters</p>
<span className="font-mono text-[9px] text-[var(--muted-foreground)]">
{editingProfile.strategy_config?.rules?.filter(r => r.enabled).length || 0}/{AVAILABLE_RULES.length} active
</span>
</div>
{AVAILABLE_RULES.map(rule => {
const ruleConfig = editingProfile.strategy_config?.rules?.find(r => r.ruleId === rule.id);
const active = ruleConfig?.enabled;
const Icon = rule.icon;
return (
<div key={rule.id} className={`overflow-hidden rounded-xl border transition-all ${active
? 'border-[var(--border-strong)] bg-[var(--card-elevated)]'
: 'border-[var(--border)] bg-[var(--muted)]/35'
}`}>
<div onClick={() => toggleRule(rule.id)} className="px-4 py-3 flex items-center justify-between cursor-pointer group">
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center transition-all"
style={{
background: active ? `${rule.color}15` : 'rgba(255,255,255,0.02)',
border: `1px solid ${active ? `${rule.color}30` : 'rgba(255,255,255,0.04)'}`,
}}
>
<Icon size={14} style={{ color: active ? rule.color : '#52525b' }} />
</div>
<div>
<div className="flex items-center gap-2 mb-0.5">
<span className={`text-xs font-bold transition-colors ${active ? 'text-[var(--foreground)]' : 'text-[var(--muted-foreground)] group-hover:text-[var(--foreground)]'}`}>{rule.name}</span>
{(() => {
// Use the stored ruleType from the DB first.
// Only fall back to 'mandatory' for RiskManagementRule.
// SessionRule can be toggled freely by the admin.
const currentType = ruleConfig?.ruleType
|| (rule.id === 'RiskManagementRule' ? 'mandatory' : 'voting');
const isMandatory = currentType === 'mandatory';
return (
<button
onClick={(e) => {
e.stopPropagation();
const newRules = editingProfile.strategy_config?.rules?.map(r =>
r.ruleId === rule.id ? { ...r, ruleType: isMandatory ? 'voting' : 'mandatory' as const } : r
);
setEditingProfile({
...editingProfile,
strategy_config: { ...editingProfile.strategy_config, rules: newRules } as StrategyConfig
});
}}
title="Click to toggle Mandatory/Voting"
className={`px-1.5 py-0.5 rounded text-[8px] font-bold uppercase tracking-tighter border transition-all ${isMandatory
? 'bg-amber-500/10 text-amber-500 border-amber-500/20 hover:bg-amber-500/20'
: 'bg-blue-500/10 text-blue-400 border-blue-500/20 hover:bg-blue-500/20'
}`}
>
{isMandatory ? 'Mandatory' : 'Voting'}
</button>
);
})()}
</div>
<p className="text-[10px] leading-tight text-[var(--muted-foreground)]">{rule.desc}</p>
</div>
</div>
<ToggleSwitch checked={active || false} onChange={() => toggleRule(rule.id)} />
</div>
{/* Expanded params */}
{active && (
<div className="px-4 pb-3.5 pt-0">
<div className="mb-3 h-px bg-[var(--border)]" />
<div className="grid grid-cols-2 gap-2">
{Object.entries(rule.defaultParams || {}).map(([key, defaultVal]) => {
const currentVal = ruleConfig?.params?.[key] ?? defaultVal;
if (rule.id === 'SessionRule' && key === 'sessions') {
const selectedPreset = resolveSessionPresetSelection(currentVal);
const selectedPresetHint = selectedPreset !== 'custom'
? SESSION_PRESET_OPTIONS.find((option) => option.value === selectedPreset)?.hint
: null;
return (
<div key={key} className="space-y-1 col-span-2">
<label className="text-[9px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">{PARAM_LABELS[key] || key}</label>
<Select
value={selectedPreset}
onChange={(e) => {
const nextValue = e.target.value;
if (nextValue === 'custom') return;
updateRuleParam(rule.id, key, nextValue);
}}
className="h-9 rounded-lg px-2.5 py-1.5 font-mono text-[11px]"
>
{SESSION_PRESET_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
{selectedPreset === 'custom' && (
<option value="custom">Custom (manual)</option>
)}
</Select>
{selectedPreset === 'custom' && (
<Input
type="text"
value={String(currentVal)}
onChange={(e) => updateRuleParam(rule.id, key, e.target.value)}
placeholder="LDN,NY or 24/7"
className="h-9 rounded-lg px-2.5 py-1.5 font-mono text-[11px]"
/>
)}
<p className="text-[9px] text-[var(--muted-foreground)]">
{selectedPresetHint || 'Use comma-separated codes like LDN,NY,TOK,SYD or 24/7.'}
</p>
</div>
);
}
return (
<div key={key} className="space-y-1">
<label className="text-[9px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">{PARAM_LABELS[key] || key}</label>
<Input
type={typeof defaultVal === 'number' ? 'number' : 'text'}
value={currentVal}
onChange={(e) => {
const val = typeof defaultVal === 'number' ? parseFloat(e.target.value) : e.target.value;
updateRuleParam(rule.id, key, val);
}}
className="h-9 rounded-lg px-2.5 py-1.5 font-mono text-[11px]"
/>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
)}
{drawerTab === 'advanced' && (
<div className="space-y-5">
{/* Capital & Risk */}
<div>
<h4 className="mb-3 flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
<DollarSign size={11} className={accentTextClass} />
Capital & Risk
</h4>
<div className="space-y-3">
<Slider min={100} max={50000} step={100} value={editingProfile.allocated_capital || 1000} onChange={v => setEditingProfile({ ...editingProfile, allocated_capital: v })} unit="$" label="Allocated Capital" />
<Slider min={0.1} max={10} step={0.1} value={editingProfile.risk_per_trade_percent || 1} onChange={v => setEditingProfile({ ...editingProfile, risk_per_trade_percent: v })} unit="%" label="Risk Per Trade" />
</div>
</div>
{/* Risk Limits */}
<div>
<h4 className="mb-3 flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
<Shield size={11} className="text-amber-400" />
Risk Limits
</h4>
<div className="space-y-3">
<Slider
min={10} max={500} step={10}
value={editingProfile.strategy_config?.riskLimits?.maxDailyLossUsd || 50}
onChange={v => setEditingProfile({
...editingProfile,
strategy_config: {
...editingProfile.strategy_config!,
riskLimits: { ...editingProfile.strategy_config!.riskLimits!, maxDailyLossUsd: v }
}
})}
unit="$" label="Max Daily Loss"
/>
<Slider
min={1} max={10} step={1}
value={editingProfile.strategy_config?.riskLimits?.maxOpenTrades || 3}
onChange={v => setEditingProfile({
...editingProfile,
strategy_config: {
...editingProfile.strategy_config!,
riskLimits: { ...editingProfile.strategy_config!.riskLimits!, maxOpenTrades: v }
}
})}
unit="" label="Max Open Trades"
/>
<Slider
min={0} max={5} step={1}
value={editingProfile.strategy_config?.riskLimits?.maxConsecutiveLosses ?? 2}
onChange={v => setEditingProfile({
...editingProfile,
strategy_config: {
...editingProfile.strategy_config!,
riskLimits: { ...editingProfile.strategy_config!.riskLimits!, maxConsecutiveLosses: v }
}
})}
unit="" label="Max Consecutive Losses"
/>
<Slider
min={10} max={1000} step={10}
value={editingProfile.strategy_config?.riskLimits?.dailyProfitTargetUsd || 100}
onChange={v => setEditingProfile({
...editingProfile,
strategy_config: {
...editingProfile.strategy_config!,
riskLimits: { ...editingProfile.strategy_config!.riskLimits!, dailyProfitTargetUsd: v }
}
})}
unit="$" label="Daily Profit Target (Halt)"
/>
</div>
</div>
{/* Execution Settings */}
<div>
<h4 className="mb-3 flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
<Zap size={11} className="text-blue-400" />
Execution
</h4>
<div className="space-y-3">
<div className={cn('space-y-1.5 p-4', subtlePanelClass)}>
<label className={labelClass}>Order Type</label>
<div className="flex gap-2">
{(['market', 'limit'] as const).map(t => (
<button
key={t}
onClick={() => setEditingProfile({
...editingProfile,
strategy_config: {
...editingProfile.strategy_config!,
execution: { ...editingProfile.strategy_config!.execution!, orderType: t }
}
})}
className={`flex-1 rounded-lg border py-2 text-[10px] font-bold uppercase tracking-wider transition-all ${(editingProfile.strategy_config?.execution?.orderType || 'market') === t
? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]'
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
{t}
</button>
))}
</div>
</div>
<div className={cn('space-y-1.5 p-4', subtlePanelClass)}>
<label className={labelClass}>Entry Mode</label>
<div className="flex gap-2">
{([
{ value: 'both', label: 'Both Sides' },
{ value: 'long_only', label: 'Long Only' }
] as const).map(mode => (
<button
key={mode.value}
onClick={() => setEditingProfile({
...editingProfile,
strategy_config: {
...editingProfile.strategy_config!,
execution: { ...editingProfile.strategy_config!.execution!, entryMode: mode.value }
}
})}
className={`flex-1 rounded-lg border py-2 text-[10px] font-bold uppercase tracking-wider transition-all ${(editingProfile.strategy_config?.execution?.entryMode || 'both') === mode.value
? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]'
: 'border-[var(--border)] bg-[var(--card)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
{mode.label}
</button>
))}
</div>
<p className={helpTextClass}>
Long-only blocks new SELL entries while keeping SELL exits enabled.
</p>
</div>
<Slider
min={5} max={120} step={5}
value={editingProfile.strategy_config?.execution?.cooldownMinutes || 30}
onChange={v => setEditingProfile({
...editingProfile,
strategy_config: {
...editingProfile.strategy_config!,
execution: { ...editingProfile.strategy_config!.execution!, cooldownMinutes: v }
}
})}
unit="m" label="Cooldown After Trade"
/>
<Slider
min={0.5} max={1.0} step={0.05}
value={editingProfile.strategy_config?.execution?.minRulePassRatio || 1.0}
onChange={v => setEditingProfile({
...editingProfile,
strategy_config: {
...editingProfile.strategy_config!,
execution: { ...editingProfile.strategy_config!.execution!, minRulePassRatio: v }
}
})}
unit="" label="Voting Pass Threshold"
/>
<p className="px-4 text-[10px] text-[var(--muted-foreground)]">
Minimum ratio of voting rules that must pass (1.0 = All must pass).
</p>
</div>
</div>
</div>
)}
</div>
{/* Drawer footer */}
<div className="flex gap-3 border-t border-[var(--border)] bg-[var(--card)] px-6 py-4">
<Button
onClick={() => setIsDrawerOpen(false)}
variant="outline"
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={loading}
className="flex-[2]"
>
<Save size={13} />
{loading ? 'Saving...' : isAddingMode ? 'Create Profile' : 'Save Changes'}
</Button>
</div>
</div>
</div>,
document.body
)}
{/* ─── DELETE CONFIRMATION ─── */}
{deleteConfirm && createPortal(
<div className="fixed inset-0 z-[999999] flex items-center justify-center p-6">
<div className="absolute inset-0 bg-slate-950/75 backdrop-blur-sm" onClick={() => setDeleteConfirm(null)} />
<Card className="relative w-full max-w-[360px] rounded-[20px] border-red-500/20 p-7">
<div className="flex flex-col items-center text-center">
<div className="w-12 h-12 bg-rose-500/10 rounded-xl flex items-center justify-center text-rose-500 mb-4 border border-rose-500/20">
<Trash2 size={20} />
</div>
<h3 className="mb-1.5 text-base font-bold text-[var(--foreground)]">Delete Profile?</h3>
<p className="mb-5 max-w-[250px] text-[11px] leading-relaxed text-[var(--muted-foreground)]">
This will permanently remove this strategy profile and cannot be undone.
</p>
<div className="flex gap-2.5 w-full">
<Button
onClick={() => setDeleteConfirm(null)}
variant="outline"
className="flex-1"
>
Cancel
</Button>
<Button
onClick={() => handleDelete(deleteConfirm)}
variant="destructive"
className="flex-1"
>
Delete
</Button>
</div>
</div>
</Card>
</div>,
document.body
)}
{/* ─── TOASTS ─── */}
<div className="fixed bottom-6 right-6 z-[9999999] flex flex-col gap-2">
{toasts.map(t => (
<div key={t.id} className={`flex items-center gap-2.5 rounded-lg border bg-[var(--card)] px-4 py-2.5 text-[11px] font-bold shadow-[var(--card-shadow)] ${t.type === 'success' ? 'border-emerald-500/20 text-emerald-600 dark:text-emerald-400' : t.type === 'error' ? 'border-rose-500/20 text-rose-500 dark:text-rose-400' : 'border-blue-500/20 text-blue-600 dark:text-blue-400'}`}
style={{ animation: 'toastSlide 0.3s ease-out' }}
>
{t.type === 'success' ? <Check size={13} /> : t.type === 'error' ? <AlertTriangle size={13} /> : <Info size={13} />}
<span>{t.msg}</span>
</div>
))}
</div>
<style>{`
@keyframes profileDrawerSlide { from { transform: translateX(100%); opacity: 0.8; } to { transform: translateX(0); opacity: 1; } }
@keyframes toastSlide { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
`}</style>
</div>
);
};