1390 lines
88 KiB
TypeScript
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>
|
|
);
|
|
};
|