learning_ai_invt_trdg/web/src/components/TradeProfileManager.tsx

1474 lines
96 KiB
TypeScript

import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { supabase } from '../lib/supabaseClient';
import { tableNameTransactions, tableNameProfiles, tableNameUsers } from '../lib/const';
import { aggregateHistoryLedger, buildHistoryLedger } from '../lib/tradeHistoryLedger';
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';
// 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',
};
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={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors duration-200 focus:outline-none ${checked ? 'bg-[#00ff88]' : 'bg-zinc-700'}`}
>
<span className={`${checked ? 'translate-x-5' : 'translate-x-1'} inline-block h-3 w-3 transform rounded-full bg-white 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="space-y-2.5 p-4 bg-[#0f1017] border border-white/[0.04] rounded-xl">
<div className="flex justify-between items-center">
<span className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">{label}</span>
<span className="text-xs font-bold text-[#00ff88] bg-[#00ff88]/8 px-2.5 py-0.5 rounded-lg font-mono">{value}{unit}</span>
</div>
<input
type="range" min={min} max={max} step={step || 1} value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full accent-[#00ff88] h-1"
/>
<div className="flex justify-between text-[9px] text-zinc-600 font-mono">
<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 }) => (
<div className="relative overflow-hidden rounded-xl" style={{
background: 'linear-gradient(135deg, #14151f 0%, #0f1017 100%)',
border: '1px solid rgba(255,255,255,0.06)',
boxShadow: '0 4px 16px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.04)',
}}>
{/* Top accent line */}
<div className="h-[2px]" style={{ background: `linear-gradient(90deg, ${color}50, ${color}15, transparent)` }} />
<div className="flex items-center gap-3.5 px-4 py-3.5">
<div className="w-9 h-9 rounded-lg flex items-center justify-center" style={{
background: `linear-gradient(135deg, ${color}18, ${color}08)`,
border: `1px solid ${color}25`,
boxShadow: `0 0 12px ${color}10`,
}}>
<Icon size={15} style={{ color }} />
</div>
<div>
<p className="text-[9px] text-zinc-500 uppercase tracking-wider font-semibold mb-0.5">{label}</p>
<p className="text-sm font-bold text-white font-mono leading-none">{value}</p>
</div>
</div>
</div>
);
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 [users, setUsers] = useState<User[]>([]);
const [tradeStats, setTradeStats] = useState<Record<string, ProfileTradeStats>>({});
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
// 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 {
let profilesQuery = supabase
.from(tableNameProfiles)
.select('id,user_id,name,allocated_capital,risk_per_trade_percent,symbols,is_active,strategy_config,created_at')
.order('created_at', { ascending: false });
let usersQuery = supabase.from(tableNameUsers).select('user_id, email');
let historyQuery = supabase
.from(tableNameTransactions)
.select('id,timestamp,symbol,side,size,entry_price,exit_price,pnl,pnl_percent,reason,profile_id,created_at,trade_id,source');
if (profile?.role !== 'admin' && authUser?.id) {
profilesQuery = profilesQuery.eq('user_id', authUser.id);
usersQuery = usersQuery.eq('user_id', authUser.id);
historyQuery = historyQuery.eq('user_id', authUser.id);
}
const [pRes, uRes, hRes] = await Promise.all([
profilesQuery,
usersQuery,
historyQuery
]);
const normalizedProfiles = (pRes.data || []).map((profile: any) => ({
...profile,
strategy_config: normalizeStrategyConfig(profile.strategy_config as StrategyConfig)
}));
setProfiles(normalizedProfiles);
setUsers(uRes.data || []);
const historyLedger = buildHistoryLedger({
dbRows: hRes.data || [],
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 || users[0]?.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 = {
name: editingProfile.name,
user_id: editingProfile.user_id || authUser?.id || users[0]?.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)
};
const { error } = id
? await supabase.from(tableNameProfiles).update(payload).eq('id', id)
: await supabase.from(tableNameProfiles).insert([payload]);
if (error) {
addToast(error.message, 'error');
} else {
addToast(isAddingMode ? 'Profile created successfully' : 'Profile updated', 'success');
setIsDrawerOpen(false);
fetchData();
}
setLoading(false);
};
const handleDelete = async (profileId: string) => {
const { error } = await supabase.from(tableNameProfiles).delete().eq('id', profileId);
if (error) {
addToast(`Delete failed: ${error.message}`, 'error');
} else {
addToast('Profile deleted', 'success');
fetchData();
}
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;
const { error } = await supabase.from(tableNameProfiles).update({ is_active: newState }).eq('id', profile.id);
if (error) {
addToast(error.message, 'error');
} else {
addToast(newState ? 'Profile activated' : 'Profile suspended', 'success');
fetchData();
}
};
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
};
const { error } = await supabase.from('strategy_presets').insert([payload]);
if (error) {
addToast(`Publish failed: ${error.message}`, 'error');
} else {
addToast('Strategy published to Marketplace!', 'success');
}
setLoading(false);
};
const filtered = filterProfilesBySearch(profiles, searchTerm);
const { activeCount, totalCapital, totalPnl, totalTrades } = summarizePortfolioStats(profiles, tradeStats);
const getUserEmail = (userId: string) => {
const u = users.find(u => u.user_id === userId);
return u ? u.email : '';
};
// --- 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="w-8 h-8 rounded-lg bg-gradient-to-br from-[#00ff88]/20 to-[#00ff88]/5 border border-[#00ff88]/15 flex items-center justify-center">
<Layers size={15} className="text-[#00ff88]" />
</div>
<h1 className="text-lg font-bold text-white">Strategy Clusters</h1>
{loading && <RotateCcw size={14} className="animate-spin text-[#00ff88]/60" />}
</div>
<p className="text-[11px] text-zinc-500 ml-11">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-zinc-600" />
<input
type="text"
placeholder="Search..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-44 bg-[#12131a] border border-white/[0.06] rounded-xl pl-8 pr-3 py-2.5 text-[11px] text-white placeholder:text-zinc-600 focus:border-[#00ff88]/30 outline-none transition-colors"
style={{ boxShadow: 'inset 0 2px 4px rgba(0,0,0,0.2)' }}
/>
</div>
<button
onClick={fetchData}
title="Refresh"
className="p-2.5 rounded-xl text-zinc-500 hover:text-[#00ff88] transition-all hover:scale-105"
style={{
background: 'linear-gradient(135deg, #14151f, #0f1017)',
border: '1px solid rgba(255,255,255,0.06)',
boxShadow: '0 2px 8px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.04)',
}}
>
<RotateCcw size={13} />
</button>
<button
onClick={handleOpenAdd}
className="flex items-center gap-1.5 px-5 py-2.5 rounded-xl text-[11px] font-bold hover:brightness-110 active:scale-[0.97] transition-all"
style={{
background: 'linear-gradient(135deg, #00ff88, #00dd77)',
color: '#000',
boxShadow: '0 4px 16px rgba(0,255,136,0.2), inset 0 1px 0 rgba(255,255,255,0.2)',
}}
>
<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 && (
<div className="flex flex-col items-center justify-center py-20 bg-[#0f1017] border border-dashed border-white/[0.06] rounded-2xl">
<div className="w-14 h-14 rounded-2xl bg-zinc-800/50 flex items-center justify-center mb-4">
<Coins size={24} className="text-zinc-700" />
</div>
<p className="text-sm font-bold text-zinc-500 mb-1">
{searchTerm ? 'No matching profiles' : 'No profiles yet'}
</p>
<p className="text-[11px] text-zinc-600 mb-5">
{searchTerm ? 'Try a different search term' : 'Create your first strategy profile to start trading'}
</p>
{!searchTerm && (
<button
onClick={handleOpenAdd}
className="flex items-center gap-2 bg-[#00ff88] text-black px-5 py-2.5 rounded-xl text-[11px] font-bold hover:brightness-110 transition-all"
>
<Plus size={14} />
Create Profile
</button>
)}
</div>
)}
{/* ─── 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 (
<div
key={p.id}
className={`group relative rounded-2xl transition-all duration-200 hover:shadow-2xl hover:shadow-black/40 ${p.is_active
? 'hover:translate-y-[-2px]'
: 'opacity-55'
}`}
style={{
background: 'linear-gradient(180deg, #14151f 0%, #10111a 100%)',
border: `1px solid ${p.is_active ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.04)'}`,
boxShadow: p.is_active
? '0 4px 24px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.04)'
: '0 2px 8px rgba(0,0,0,0.2)',
}}
>
{/* Top accent bar */}
<div className={`h-[3px] rounded-t-2xl ${p.is_active
? 'bg-gradient-to-r from-[#00ff88]/70 via-[#00ff88]/40 to-cyan-500/30'
: 'bg-gradient-to-r from-zinc-700/50 via-zinc-700/30 to-transparent'
}`}
/>
{/* 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="text-[15px] font-bold text-white truncate mb-2">{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
? 'bg-[#00ff88]/10 border-[#00ff88]/20 text-[#00ff88]'
: 'bg-zinc-500/10 border-zinc-500/20 text-zinc-400'
}`}>
<span className={`w-1.5 h-1.5 rounded-full ${p.is_active ? 'bg-[#00ff88] animate-pulse' : 'bg-zinc-500'}`} />
{p.is_active ? 'Active' : 'Paused'}
</span>
{email && (
<span className="text-[10px] text-zinc-600 truncate max-w-[160px]">{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'}
className={`w-8 h-8 rounded-xl flex items-center justify-center border transition-all hover:scale-105 ${p.is_active
? 'bg-[#00ff88]/10 border-[#00ff88]/20 text-[#00ff88] hover:bg-[#00ff88]/20'
: 'bg-white/[0.03] border-white/[0.06] text-zinc-500 hover:text-[#00ff88] hover:border-[#00ff88]/20'
}`}
>
<Zap size={14} fill={p.is_active ? 'currentColor' : 'none'} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleOpenEdit(p); }}
title="Edit profile"
className="w-8 h-8 rounded-xl flex items-center justify-center bg-white/[0.03] border border-white/[0.06] text-zinc-400 hover:text-blue-400 hover:border-blue-500/20 hover:bg-blue-500/10 transition-all hover:scale-105"
>
<Edit3 size={14} />
</button>
<button
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(p.id); }}
title="Delete profile"
className="w-8 h-8 rounded-xl flex items-center justify-center bg-white/[0.03] border border-white/[0.06] text-zinc-400 hover:text-rose-400 hover:border-rose-500/20 hover:bg-rose-500/10 transition-all hover:scale-105"
>
<Trash2 size={14} />
</button>
{profile?.role === 'admin' && (
<button
onClick={(e) => { e.stopPropagation(); handlePublish(p); }}
title="Publish to Marketplace"
className="w-8 h-8 rounded-xl flex items-center justify-center bg-white/[0.03] border border-white/[0.06] text-zinc-400 hover:text-[#00ff88] hover:border-[#00ff88]/20 hover:bg-[#00ff88]/10 transition-all hover:scale-105"
>
<Share2 size={14} />
</button>
)}
</div>
</div>
</div>
{/* ── Elevated inner panel: Key Metrics ── */}
<div className="mx-4 mb-4 rounded-xl overflow-hidden" style={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.008) 100%)',
border: '1px solid rgba(255,255,255,0.06)',
boxShadow: '0 3px 12px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.05)',
}}>
{/* Stats header row */}
<div className="grid grid-cols-3">
{[
{ icon: DollarSign, label: 'Capital', val: `$${p.allocated_capital.toLocaleString()}`, color: '#3b82f6', valColor: 'white' },
{ icon: BarChart3, label: 'Realized', val: `${stats.totalPnl >= 0 ? '+' : ''}${stats.totalPnl.toFixed(2)}`, color: stats.totalPnl >= 0 ? '#00ff88' : '#ef4444', valColor: stats.totalPnl >= 0 ? '#00ff88' : '#ef4444' },
{ icon: AlertTriangle, label: 'Risk', val: `${p.risk_per_trade_percent}%`, color: '#f59e0b', valColor: 'white' },
].map((item, i) => (
<div key={item.label} className="relative px-4 py-4 text-center" style={{
borderRight: i < 2 ? '1px solid rgba(255,255,255,0.04)' : 'none',
}}>
{/* Subtle column glow */}
<div className="absolute inset-0 opacity-30" style={{
background: `radial-gradient(ellipse at center bottom, ${item.color}08, transparent 70%)`,
}} />
<div className="relative">
<div className="flex items-center justify-center gap-1.5 mb-1.5">
<div className="w-4 h-4 rounded flex items-center justify-center" style={{
background: `${item.color}15`,
}}>
<item.icon size={9} style={{ color: item.color }} />
</div>
<span className="text-[8px] text-zinc-500 uppercase tracking-widest font-bold">{item.label}</span>
</div>
<span className="text-[14px] font-bold font-mono" style={{ color: item.valColor }}>{item.val}</span>
</div>
</div>
))}
</div>
<div className="profile-health-strip px-4 py-2" style={{ display: 'flex', flexWrap: 'wrap', gap: '4px' }}>
<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="px-4 py-3" style={{
borderTop: '1px solid rgba(255,255,255,0.04)',
background: 'rgba(0,0,0,0.15)',
}}>
<div className="flex justify-between items-center mb-2">
<span className="text-[9px] text-zinc-500 font-bold uppercase tracking-wider">Win Rate</span>
<span className="text-[10px] font-bold text-zinc-400 font-mono">{stats.winRate.toFixed(1)}% · {stats.tradeCount} trades</span>
</div>
<div className="h-[7px] w-full rounded-full overflow-hidden" style={{
background: 'rgba(0,0,0,0.4)',
boxShadow: 'inset 0 1px 3px rgba(0,0,0,0.3)',
}}>
<div
className="h-full rounded-full transition-all duration-1000"
style={{
width: `${Math.max(stats.winRate, 2)}%`,
background: stats.winRate >= 50
? 'linear-gradient(90deg, #00ff88, #00cc6a)'
: stats.winRate >= 30
? 'linear-gradient(90deg, #f59e0b, #d97706)'
: 'linear-gradient(90deg, #ef4444, #dc2626)',
boxShadow: stats.winRate >= 50
? '0 0 10px rgba(0,255,136,0.35), inset 0 1px 0 rgba(255,255,255,0.2)'
: 'inset 0 1px 0 rgba(255,255,255,0.15)',
}}
/>
</div>
</div>
</div>
{/* ── Elevated inner panel: Details Table ── */}
<div className="mx-4 mb-4 rounded-xl overflow-hidden" style={{
background: 'linear-gradient(180deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.005) 100%)',
border: '1px solid rgba(255,255,255,0.05)',
boxShadow: '0 2px 10px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.04)',
}}>
{/* Table header */}
<div className="px-4 py-2 flex items-center gap-2" style={{
background: 'linear-gradient(90deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01))',
borderBottom: '1px solid rgba(255,255,255,0.04)',
}}>
<Settings size={9} className="text-zinc-600" />
<span className="text-[8px] text-zinc-500 uppercase tracking-widest font-bold">Configuration</span>
</div>
<table className="w-full text-left">
<tbody>
<tr style={{ background: 'rgba(255,255,255,0.008)' }}>
<td className="px-4 py-2.5 text-[10px] text-zinc-500 font-medium">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: `linear-gradient(135deg, ${ruleDef.color}18, ${ruleDef.color}08)`,
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="text-[9px] text-zinc-500 font-mono font-bold ml-0.5">+{ruleCount - 5}</span>
)}
</div>
</td>
</tr>
<tr style={{ borderTop: '1px solid rgba(255,255,255,0.03)' }}>
<td className="px-4 py-2.5 text-[10px] text-zinc-500 font-medium">Max Daily Loss</td>
<td className="px-4 py-2.5 text-right">
<span className="text-[11px] font-mono font-bold px-2 py-0.5 rounded-md" style={{
color: '#ef4444',
background: 'rgba(239,68,68,0.08)',
border: '1px solid rgba(239,68,68,0.15)',
}}>${maxLoss}</span>
</td>
</tr>
<tr style={{ background: 'rgba(255,255,255,0.008)', borderTop: '1px solid rgba(255,255,255,0.03)' }}>
<td className="px-4 py-2.5 text-[10px] text-zinc-500 font-medium">Max Open Trades</td>
<td className="px-4 py-2.5 text-right">
<span className="text-[11px] font-mono font-bold px-2 py-0.5 rounded-md" style={{
color: '#a855f7',
background: 'rgba(168,85,247,0.08)',
border: '1px solid rgba(168,85,247,0.15)',
}}>{maxOpen}</span>
</td>
</tr>
<tr style={{ borderTop: '1px solid rgba(255,255,255,0.03)' }}>
<td className="px-4 py-2.5 text-[10px] text-zinc-500 font-medium">Order Type</td>
<td className="px-4 py-2.5 text-right">
<span className="px-2.5 py-0.5 rounded-md text-[9px] font-bold uppercase tracking-wider" style={{
color: orderType === 'market' ? '#06b6d4' : '#f59e0b',
background: orderType === 'market' ? 'rgba(6,182,212,0.08)' : 'rgba(245,158,11,0.08)',
border: `1px solid ${orderType === 'market' ? 'rgba(6,182,212,0.2)' : 'rgba(245,158,11,0.2)'}`,
boxShadow: `0 1px 4px ${orderType === 'market' ? 'rgba(6,182,212,0.08)' : 'rgba(245,158,11,0.08)'}`,
}}>
{orderType}
</span>
</td>
</tr>
<tr style={{ background: 'rgba(255,255,255,0.008)', borderTop: '1px solid rgba(255,255,255,0.03)' }}>
<td className="px-4 py-2.5 text-[10px] text-zinc-500 font-medium">Cooldown</td>
<td className="px-4 py-2.5 text-right">
<span className="text-[11px] font-mono font-bold px-2 py-0.5 rounded-md" style={{
color: '#3b82f6',
background: 'rgba(59,130,246,0.08)',
border: '1px solid rgba(59,130,246,0.15)',
}}>{cooldown}m</span>
</td>
</tr>
<tr style={{ borderTop: '1px solid rgba(255,255,255,0.03)' }}>
<td className="px-4 py-2.5 text-[10px] text-zinc-500 font-medium">Entry Mode</td>
<td className="px-4 py-2.5 text-right">
<span className="px-2.5 py-0.5 rounded-md text-[9px] font-bold uppercase tracking-wider" style={{
color: entryMode === 'long_only' ? '#f59e0b' : '#00ff88',
background: entryMode === 'long_only' ? 'rgba(245,158,11,0.08)' : 'rgba(0,255,136,0.08)',
border: `1px solid ${entryMode === 'long_only' ? 'rgba(245,158,11,0.2)' : 'rgba(0,255,136,0.2)'}`,
}}>
{entryMode === 'long_only' ? 'LONG ONLY' : 'BOTH SIDES'}
</span>
</td>
</tr>
{profitTarget && (
<tr style={{ background: 'rgba(255,255,255,0.008)', borderTop: '1px solid rgba(255,255,255,0.03)' }}>
<td className="px-4 py-2.5 text-[10px] text-zinc-500 font-medium">Profit Target</td>
<td className="px-4 py-2.5 text-right">
<span className="text-[11px] font-mono font-bold px-2 py-0.5 rounded-md" style={{
color: '#00ff88',
background: 'rgba(0,255,136,0.08)',
border: '1px solid rgba(0,255,136,0.15)',
}}>${profitTarget}</span>
</td>
</tr>
)}
<tr style={{ borderTop: '1px solid rgba(255,255,255,0.03)' }}>
<td className="px-4 py-2.5 text-[10px] text-zinc-500 font-medium">Voting Threshold</td>
<td className="px-4 py-2.5 text-right">
<span className={`text-[11px] font-mono font-bold px-2 py-0.5 rounded-md ${minPassRatio < 1.0 ? 'text-amber-400 bg-amber-400/8 border-amber-400/20' : 'text-zinc-400 bg-white/5 border-white/10'}`} style={{
border: '1px solid',
}}>{(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="px-2.5 py-1 rounded-lg text-[10px] font-mono text-zinc-400 font-medium" style={{
background: 'rgba(255,255,255,0.025)',
border: '1px solid rgba(255,255,255,0.05)',
}}>
{s.trim()}
</span>
))}
{p.symbols.split(',').length > 5 && (
<span className="px-2.5 py-1 rounded-lg text-[10px] font-mono text-zinc-600" style={{
background: 'rgba(255,255,255,0.015)',
border: '1px solid rgba(255,255,255,0.04)',
}}>
+{p.symbols.split(',').length - 5}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
{/* ─── EDIT / CREATE DRAWER ─── */}
{isDrawerOpen && createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 999999, display: 'flex', justifyContent: 'flex-end' }}>
<div
style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(8px)' }}
onClick={() => setIsDrawerOpen(false)}
/>
<div style={{
position: 'relative',
width: '520px',
maxWidth: '92vw',
background: '#0a0b10',
height: '100%',
borderLeft: '1px solid rgba(255,255,255,0.05)',
display: 'flex',
flexDirection: 'column',
animation: 'profileDrawerSlide 0.2s ease-out'
}}>
{/* Drawer header */}
<div className="px-6 py-5 border-b border-white/[0.04]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-xl flex items-center justify-center ${isAddingMode
? 'bg-[#00ff88]/10 border border-[#00ff88]/20'
: 'bg-blue-500/10 border border-blue-500/20'
}`}>
{isAddingMode
? <Plus size={16} className="text-[#00ff88]" />
: <Edit3 size={16} className="text-blue-400" />
}
</div>
<div>
<h2 className="text-base font-bold text-white">{isAddingMode ? 'Create Profile' : 'Edit Profile'}</h2>
<p className="text-[10px] text-zinc-500 mt-0.5">{isAddingMode ? 'Configure a new trading strategy' : editingProfile.name}</p>
</div>
</div>
<button onClick={() => setIsDrawerOpen(false)} className="w-8 h-8 rounded-lg bg-white/[0.03] border border-white/[0.06] flex items-center justify-center text-zinc-500 hover:text-white hover:bg-white/[0.06] transition-all">
<X size={15} />
</button>
</div>
{/* Drawer tab nav */}
<div className="flex bg-[#0f1017] border border-white/[0.04] rounded-lg 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-[#00ff88] text-black shadow-sm'
: 'text-zinc-500 hover:text-zinc-300'
}`}
>
<t.icon size={11} />
{t.label}
</button>
))}
</div>
</div>
{/* Drawer content */}
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5" style={{ scrollbarWidth: 'thin', scrollbarColor: 'rgba(0,255,136,0.08) transparent' }}>
{drawerTab === 'settings' && (
<>
{/* Profile Name */}
<div className="space-y-1.5">
<label style={{ fontSize: '10px', fontWeight: 600, color: '#a1a1aa', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Profile Name</label>
<input
type="text"
value={editingProfile.name || ''}
onChange={e => setEditingProfile({ ...editingProfile, name: e.target.value })}
className="w-full rounded-lg px-4 py-2.5 text-sm outline-none transition-colors"
placeholder="e.g. BTC Momentum Scalper"
style={{
background: '#161722',
border: '1px solid rgba(255,255,255,0.12)',
color: '#ffffff',
caretColor: '#00ff88',
}}
/>
</div>
{/* Current User (read-only) */}
<div className="space-y-1.5">
<label style={{ fontSize: '10px', fontWeight: 600, color: '#a1a1aa', textTransform: 'uppercase', letterSpacing: '0.05em' }}>User</label>
<div className="w-full rounded-lg px-4 py-2.5 flex items-center gap-2" style={{
background: '#161722',
border: '1px solid rgba(255,255,255,0.08)',
}}>
<div style={{
width: '20px',
height: '20px',
borderRadius: '50%',
background: 'linear-gradient(135deg, rgba(0,255,136,0.2), rgba(0,255,136,0.05))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '9px',
fontWeight: 700,
color: '#00ff88',
}}>
{(authUser?.email || '?').charAt(0).toUpperCase()}
</div>
<span style={{ fontSize: '13px', color: '#ffffff' }}>{authUser?.email || 'Current User'}</span>
<span style={{ fontSize: '9px', color: '#00ff88', marginLeft: 'auto', background: 'rgba(0,255,136,0.1)', padding: '2px 8px', borderRadius: '6px', fontWeight: 700 }}>You</span>
</div>
</div>
{/* Trading Symbols */}
<div className="space-y-1.5">
<label style={{ fontSize: '10px', fontWeight: 600, color: '#a1a1aa', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Trading Symbols</label>
<textarea
value={editingProfile.symbols || ''}
onChange={e => setEditingProfile({ ...editingProfile, symbols: e.target.value })}
className="w-full rounded-lg px-4 py-2.5 text-sm font-mono h-20 outline-none transition-colors resize-none"
placeholder="BTC/USDT, ETH/USDT, SOL/USDT"
style={{
background: '#161722',
border: '1px solid rgba(255,255,255,0.12)',
color: '#ffffff',
caretColor: '#00ff88',
}}
/>
<p style={{ fontSize: '9px', color: '#71717a' }}>Comma-separated trading pairs</p>
</div>
{/* Active Toggle */}
<div className="flex items-center justify-between p-4 bg-[#0f1017] border border-white/[0.04] rounded-xl">
<div className="flex items-center gap-3">
<Zap size={14} className={editingProfile.is_active ? 'text-[#00ff88]' : 'text-zinc-600'} />
<div>
<p className="text-xs font-semibold text-white">Profile Active</p>
<p className="text-[10px] text-zinc-600">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="text-[10px] text-zinc-500">Toggle rules on/off and configure parameters</p>
<span className="text-[9px] text-zinc-600 font-mono">
{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={`rounded-xl border transition-all overflow-hidden ${active
? 'border-white/[0.08] bg-white/[0.01]'
: 'border-white/[0.03] bg-[#0f1017]'
}`}>
<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-white' : 'text-zinc-500 group-hover:text-zinc-400'}`}>{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] text-zinc-600 leading-tight">{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="h-px bg-white/[0.04] mb-3" />
<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 text-zinc-500 uppercase tracking-wider">{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="w-full rounded-lg px-2.5 py-1.5 text-[11px] font-mono outline-none transition-colors"
style={{ background: '#161722', border: '1px solid rgba(255,255,255,0.12)', color: '#ffffff' }}
>
{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="w-full rounded-lg px-2.5 py-1.5 text-[11px] font-mono outline-none transition-colors"
style={{ background: '#161722', border: '1px solid rgba(255,255,255,0.12)', color: '#ffffff', caretColor: '#00ff88' }}
/>
)}
<p className="text-[9px] text-zinc-600">
{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 text-zinc-500 uppercase tracking-wider">{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="w-full rounded-lg px-2.5 py-1.5 text-[11px] font-mono outline-none transition-colors"
style={{ background: '#161722', border: '1px solid rgba(255,255,255,0.12)', color: '#ffffff', caretColor: '#00ff88' }}
/>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
)}
{drawerTab === 'advanced' && (
<div className="space-y-5">
{/* Capital & Risk */}
<div>
<h4 className="text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<DollarSign size={11} className="text-[#00ff88]" />
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="text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<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="text-[10px] font-bold text-zinc-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<Zap size={11} className="text-blue-400" />
Execution
</h4>
<div className="space-y-3">
<div className="p-4 bg-[#0f1017] border border-white/[0.04] rounded-xl space-y-1.5">
<label className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">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 py-2 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${(editingProfile.strategy_config?.execution?.orderType || 'market') === t
? 'bg-[#00ff88] text-black'
: 'bg-white/[0.03] border border-white/[0.06] text-zinc-500 hover:text-white'
}`}
>
{t}
</button>
))}
</div>
</div>
<div className="p-4 bg-[#0f1017] border border-white/[0.04] rounded-xl space-y-1.5">
<label className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">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 py-2 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${(editingProfile.strategy_config?.execution?.entryMode || 'both') === mode.value
? 'bg-[#00ff88] text-black'
: 'bg-white/[0.03] border border-white/[0.06] text-zinc-500 hover:text-white'
}`}
>
{mode.label}
</button>
))}
</div>
<p className="text-[10px] text-zinc-500">
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="text-[10px] text-zinc-500 px-4">
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 px-6 py-4 border-t border-white/[0.04] bg-[#0a0b10]">
<button
onClick={() => setIsDrawerOpen(false)}
className="flex-1 py-2.5 rounded-lg border border-white/[0.06] text-[11px] font-bold text-zinc-500 hover:text-white hover:border-white/[0.1] transition-all"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={loading}
className="flex-[2] py-2.5 rounded-lg bg-[#00ff88] text-black text-[11px] font-bold flex items-center justify-center gap-2 hover:brightness-110 active:scale-[0.98] transition-all disabled:opacity-50 shadow-lg shadow-[#00ff88]/10"
>
<Save size={13} />
{loading ? 'Saving...' : isAddingMode ? 'Create Profile' : 'Save Changes'}
</button>
</div>
</div>
</div>,
document.body
)}
{/* ─── DELETE CONFIRMATION ─── */}
{deleteConfirm && createPortal(
<div style={{ position: 'fixed', inset: 0, zIndex: 999999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px' }}>
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.8)', backdropFilter: 'blur(8px)' }} onClick={() => setDeleteConfirm(null)} />
<div style={{ position: 'relative', width: '100%', maxWidth: '360px', background: '#0a0b10', borderRadius: '20px', border: '1px solid rgba(244,63,94,0.12)', padding: '28px' }}>
<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="text-base font-bold text-white mb-1.5">Delete Profile?</h3>
<p className="text-[11px] text-zinc-500 leading-relaxed mb-5 max-w-[250px]">
This will permanently remove this strategy profile and cannot be undone.
</p>
<div className="flex gap-2.5 w-full">
<button
onClick={() => setDeleteConfirm(null)}
className="flex-1 py-2.5 rounded-lg border border-white/[0.06] text-[11px] font-bold text-zinc-400 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirm)}
className="flex-1 py-2.5 rounded-lg bg-rose-600 text-white text-[11px] font-bold hover:bg-rose-500 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
</div>,
document.body
)}
{/* ─── TOASTS ─── */}
<div style={{ position: 'fixed', bottom: '24px', right: '24px', zIndex: 9999999, display: 'flex', flexDirection: 'column', gap: '8px' }}>
{toasts.map(t => (
<div key={t.id} className={`px-4 py-2.5 rounded-lg shadow-2xl border flex items-center gap-2.5 bg-[#0a0b10] text-[11px] font-bold ${t.type === 'success' ? 'border-[#00ff88]/20 text-[#00ff88]' : t.type === 'error' ? 'border-rose-500/20 text-rose-400' : 'border-blue-500/20 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>
);
};