// Admin screens: GodView, Approvals, KillSwitch, Settings, ExchangeBalance, AdminBotDetail.
const REGIME_SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT'];
function _recommendationTone(rec) {
if (!rec) return 'muted';
const r = rec.toUpperCase();
if (r.includes('MOMENTUM')) return 'pos';
if (r.includes('MEAN') || r.includes('VWAP')) return 'warn';
if (r.includes('BREAKOUT')) return 'warn';
if (r.includes('STAND') || r.includes('FLAT') || r.includes('ASIDE')) return 'neg';
return 'muted';
}
function _volTone(vol) {
if (vol === 'LOW') return 'pos';
if (vol === 'NORMAL') return 'warn';
if (vol === 'HIGH') return 'warn';
if (vol === 'EXTREME') return 'neg';
return 'muted';
}
function RegimeCard() {
const [symbol, setSymbol] = React.useState('BTCUSDT');
const [data, setData] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const [err, setErr] = React.useState(null);
const [refreshedAt, setRefreshedAt] = React.useState(null);
const fetchSnap = React.useCallback(async (sym) => {
setLoading(true); setErr(null);
try {
const d = await api.get('/api/admin/regime/' + encodeURIComponent(sym));
setData(d);
setRefreshedAt(new Date());
} catch (e) {
setErr(e.detail || e.message || 'fetch failed');
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
let alive = true;
fetchSnap(symbol);
const t = setInterval(() => { if (alive) fetchSnap(symbol); }, 5 * 60 * 1000);
return () => { alive = false; clearInterval(t); };
}, [symbol, fetchSnap]);
const recTone = data ? _recommendationTone(data.recommendation) : 'muted';
const volTone = data ? _volTone(data.vol_regime) : 'muted';
return (
Market regime
Live read from Bybit V5 · auto-refresh 5 min
{refreshedAt && · last: {refreshedAt.toLocaleTimeString()} }
setSymbol(e.target.value)}
style={{ background: 'var(--card)', color: 'var(--ink)', border: '1px solid var(--line)', borderRadius: 8, padding: '6px 10px', fontWeight: 600 }}
>
{REGIME_SYMBOLS.map(s => {s} )}
fetchSnap(symbol)} disabled={loading}>
{loading ? '...' : 'Refresh'}
{err &&
Error: {err}
}
{data && (
${fmtNum(data.price, 2)}
Trend {data.trend_score}/4
Vol {data.vol_regime}
{data.recommendation}
{data.rationale}
)}
{!data && !err && loading && (
Fetching regime…
)}
);
}
function RegimeMetric({ label, value }) {
return (
);
}
function AdminGodView({ go }) {
const [dash] = useApi('/api/dashboard/admin');
const [bots] = useApi('/api/bots');
const [snapshots, setSnapshots] = React.useState({});
const [bal, balActions] = useApi('/api/admin/exchange/balance');
// Per-bot scanner snapshot polling (admin overview)
React.useEffect(() => {
if (!bots) return;
let alive = true;
const fetchAll = async () => {
const out = {};
await Promise.all(bots.filter(b => b.is_active && !b.is_kill_switched).map(async (b) => {
try { out[b.id] = await api.get(`/api/admin/bots/${b.id}/scanner`); } catch {}
}));
if (alive) setSnapshots(out);
};
fetchAll();
const t = setInterval(fetchAll, 15000);
return () => { alive = false; clearInterval(t); };
}, [bots]);
return (
<>
Admin · God view
Command center
{dash && All systems live }
go('settings')}> Settings
= 0} neg={dash?.platform_pnl < 0}/>
{/* Bybit wallet vs platform AUM reconciliation. The platform's
internal accounting (Live AUM + Company Reserve) is bookkeeping —
Bybit Wallet Balance is the real exchange total. The gap is
UNINVESTED CASH on the exchange that hasn't been allocated to
any bot via the subscribe flow. */}
{bal && (() => {
const platformTotal = (dash?.total_aum || 0) + (dash?.company_reserve || 0);
const bybitTotal = bal.total_margin_balance || bal.total_wallet_balance || 0;
const diff = bybitTotal - platformTotal;
const borderColor = Math.abs(diff) < 1 ? 'var(--pos)'
: (diff > 0 ? 'var(--ink-3)' : 'var(--neg)');
const label = Math.abs(diff) < 1 ? 'Reconciled'
: (diff > 0 ? 'Uninvested cash on exchange' : 'PLATFORM AUM > BYBIT — investigate');
return (
Exchange reconciliation {bal.testnet ? '(testnet)' : '(mainnet)'}
Bybit wallet
{USD(bybitTotal)}
Platform (AUM + Reserve)
{USD(platformTotal)}
Diff
{USD(diff, { sign: true })}
{label}
);
})()}
{(dash?.paper_active_bots || 0) > 0 && (
Paper-trading totals (simulated, not real money)
AUM
{USD(dash?.paper_aum)}
PnL
= 0 ? 'var(--pos)' : 'var(--neg)' }}>
{USD(dash?.paper_pnl, { sign: true })}
Bots
{dash?.paper_active_bots}
Paper
)}
Pending queue
Deposits, withdrawals, and KYC awaiting your review.
go('approvals')}>Open queue
Bot scanner activity
Live per-bot status · auto-refreshes every 15s
go('killswitch')}>Manage
Bot Unit price AUM Status Last scan Top candidate
{(bots || []).map(b => {
const snap = snapshots[b.id];
const top = snap?.top_candidates?.[0];
const ageS = snap?.last_scan_at ? Math.round((Date.now() - new Date(snap.last_scan_at).getTime()) / 1000) : null;
return (
go('admin-bot:' + b.id)}>
{emojiFor(b.risk_category)}
{b.name}
{b.paper_mode && PAPER }
{b.risk_category}
${fmtNum(b.unit_price, 4)}
{USD(b.current_value, { decimals: 2 })}
{b.is_kill_switched ? 'KILLED' : b.is_active ? 'Active' : 'Off'}
{ageS != null ? ageS + 's ago' : '—'}
{top ? (
{top.symbol} · = 0.70 ? 'num' : 'num muted'} style={(top.score ?? 0) >= 0.70 ? { color: 'var(--pos)' } : {}}>{(top.score ?? 0).toFixed(2)}
) : — }
);
})}
>
);
}
function QueueStat({ n, l }) {
return (
);
}
function ExchangeCard({ bal, reload }) {
if (!bal) return Loading exchange balance…
;
if (bal._error || bal.detail) {
return (
Exchange balance
{typeof bal.detail === 'string' ? bal.detail : bal._error || 'Connection failed'}
);
}
return (
Bybit balance
{bal.testnet ? 'TESTNET' : 'MAINNET'}
Refresh
{USD(bal.total_wallet_balance, { decimals: 4 })}
= 0 ? 'pos' : 'neg')}>
{bal.total_unrealized_pnl >= 0 ? : }
{USD(bal.total_unrealized_pnl, { sign: true, decimals: 4 })}
Available
{USD(bal.available_balance, { decimals: 4 })}
Margin
{USD(bal.total_margin_balance, { decimals: 4 })}
Open positions
{bal.open_positions_count}
);
}
/* ---------- Approvals ---------- */
function Approvals() {
const [items, { reload: reloadTx }] = useApi('/api/transactions/pending');
const [pendingUsers, { reload: reloadUsers }] = useApi('/api/admin/users?approval_status=pending');
const [filter, setFilter] = React.useState('ALL');
const txs = items || [];
const users = pendingUsers || [];
const filtered = txs.filter(i => filter === 'ALL' || i.transaction_type.toUpperCase() === filter);
const showUsers = filter === 'ALL' || filter === 'SIGNUP';
const visibleUsers = showUsers ? users : [];
const totalPending = txs.length + users.length;
const hasPendingDeposit = txs.some(i => i.transaction_type === 'deposit');
const approveTx = async (id) => { await api.put(`/api/transactions/${id}/approve`); reloadTx(); };
const rejectTx = async (id) => {
const reason = prompt('Rejection reason:'); if (reason == null) return;
await api.put(`/api/transactions/${id}/reject?reason=${encodeURIComponent(reason)}`);
reloadTx();
};
const approveUser = async (id) => { await api.post(`/api/admin/users/${id}/approve`); reloadUsers(); };
const rejectUser = async (id) => {
const reason = prompt('Rejection reason:'); if (reason == null) return;
await api.post(`/api/admin/users/${id}/reject?reason=${encodeURIComponent(reason)}`);
reloadUsers();
};
return (
<>
Admin · Queue
Approvals · {totalPending} pending
{['ALL','SIGNUP','DEPOSIT','WITHDRAWAL'].map(f => (
setFilter(f)}>{f}
))}
{hasPendingDeposit && filter !== 'WITHDRAWAL' && filter !== 'SIGNUP' &&
}
{/* Pending user signups (with KYC details) */}
{visibleUsers.map(u => {
const kycReady = u.kyc_status === 'pending' && !!u.ktp_number;
return (
{u.full_name || u.username}
signup
{kycReady
? KYC submitted
: KYC missing }
{u.email} · @{u.username} · joined {fmtDateTime(u.created_at)}
rejectUser(u.id)}> Reject
approveUser(u.id)} disabled={!kycReady}
title={kycReady ? 'Approve signup + KYC' : 'Waiting for KYC submission'}>
Approve
{/* KYC details strip — only when submitted */}
{kycReady && (
Full name (KTP)
{u.ktp_full_name || '—'}
NIK
{u.ktp_number || '—'}
Submitted
{u.kyc_submitted_at ? fmtDateTime(u.kyc_submitted_at) : '—'}
)}
{!kycReady && (
This user hasn't filled in KYC yet. They'll be redirected to the KYC form when they sign in; approval is blocked until they submit.
)}
);
})}
{/* Pending transactions */}
{filtered.map(item => (
{item.transaction_type === 'deposit' ? : }
User #{item.user_id}
{item.transaction_type}
#{item.id} · {fmtDateTime(item.created_at)}{item.description ? ' · ' + item.description : ''}
{USD(item.amount)}
rejectTx(item.id)}> Reject
approveTx(item.id)}> Approve
))}
{filtered.length === 0 && visibleUsers.length === 0 && items && pendingUsers && (
Inbox zero
Nothing waiting in this queue.
)}
>
);
}
/* Deposit verification — shows the live Bybit USDT wallet balance + recent
on-chain deposit history so admin can match a pending request to the actual
incoming transfer before approving. Pure read-only — approval itself goes
through the existing per-row Approve button. */
function DepositVerifyPanel() {
const [bal, balActions] = useApi('/api/admin/exchange/balance');
const [deps, depActions] = useApi('/api/admin/exchange/deposits/recent');
const refresh = () => { balActions.reload(); depActions.reload(); };
const usdt = (bal?.assets || []).find(a => a.asset === 'USDT');
return (
Verify on Bybit
Check that the on-chain transfer landed before approving.
↻ Refresh
USDT wallet
{usdt ? USD(usdt.wallet_balance) : '—'}
total balance now
Available
{usdt ? USD(usdt.available_balance) : '—'}
not in open positions
Unrealized PnL
= 0 ? 'var(--pos)' : 'var(--neg)' }}>
{usdt ? USD(usdt.unrealized_pnl, { sign: true }) : '—'}
{bal?.open_positions_count ?? 0} open position(s)
Recent USDT deposits (Bybit)
{!deps &&
Loading…
}
{deps && deps.deposits && deps.deposits.length === 0 && (
No deposit records returned by Bybit yet.
)}
{deps && deps.deposits && deps.deposits.length > 0 && (
When Amount Chain Status Tx
{deps.deposits.map(d => (
{d.success_at ? new Date(d.success_at).toLocaleString() : '—'}
{USD(d.amount)}
{d.chain}
{d.status}
{d.tx_id || '—'}
))}
)}
);
}
/* ---------- KillSwitch / Bot Control ---------- */
function KillSwitch({ go }) {
const [bots, { reload }] = useApi('/api/bots');
const toggle = async (id) => { await api.post(`/api/bots/${id}/toggle-kill-switch`); reload(); };
const activeCount = (bots || []).filter(b => b.is_active && !b.is_kill_switched).length;
return (
<>
{activeCount}/{(bots || []).length} active
{(bots || []).map(b => (
{emojiFor(b.risk_category)}
{b.name}
{b.risk_category} · {USD(b.current_value, { decimals: 2 })}
toggle(b.id)}/>
go('admin-bot:' + b.id)}>Details
{b.is_kill_switched ? 'KILLED' : b.is_active ? 'Active' : 'Off'}
))}
>
);
}
/* ---------- Settings (Platform Settings) ---------- */
function Settings() {
const [list, { reload }] = useApi('/api/admin/settings');
return (
<>
Admin · Config
Platform settings
{(list || []).map(s => )}
>
);
}
function PerformanceFeeCard() {
const [data, { reload, loading }] = useApi('/api/admin/settings/performance-fee');
const [busy, setBusy] = React.useState({});
const [err, setErr] = React.useState(null);
const [flash, setFlash] = React.useState(null);
const sweep = async (botId) => {
if (!confirm('Sweep accumulated performance fee for this bot? This zeroes the counter (placeholder — no company wallet credit yet).')) return;
setBusy(b => ({ ...b, [botId]: true })); setErr(null); setFlash(null);
try {
const r = await api.post('/api/admin/settings/performance-fee/sweep', { bot_id: botId });
setFlash(`Swept ${r.swept_amount.toFixed(8)} USDT from bot ${botId}.`);
reload();
} catch (e) {
setErr(e.detail || e.message || 'sweep failed');
}
setBusy(b => ({ ...b, [botId]: false }));
};
return (
Company share
rate {data ? `${(data.rate * 100).toFixed(1)}%` : '—'}
total accumulated {data ? data.total_accumulated.toFixed(8) : '—'} USDT
Rate is edited below as PERFORMANCE_FEE_RATE. Sweep zeroes the bot's counter — for now this is a placeholder; a future iteration will credit a company wallet record and emit an audit trail.
{err &&
{err}
}
{flash &&
{flash}
}
{loading && !data ?
Loading…
: (
{(data?.per_bot || []).map(b => (
{b.bot_name} · bot {b.bot_id}
{(b.accumulated_performance_fee ?? 0).toFixed(8)} USDT
sweep(b.bot_id)}
disabled={!!busy[b.bot_id] || b.accumulated_performance_fee <= 0}
title={b.accumulated_performance_fee <= 0 ? 'nothing accrued yet' : 'sweep (placeholder)'}
>
{busy[b.bot_id] ? 'Sweeping…' : 'Sweep'}
))}
)}
);
}
function SettingRow({ s, onSaved }) {
const [val, setVal] = React.useState(
s.type === 'bool' ? String(!!s.current_value)
: s.type === 'secret' ? ''
: (s.current_value ?? '')
);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState(null);
const save = async () => {
if (s.type === 'secret' && val === '') {
setError('Paste a value to set/replace this secret. Blank is a no-op.');
return;
}
setSaving(true); setError(null);
try { await api.put(`/api/admin/settings/${encodeURIComponent(s.key)}`, { value: String(val) }); onSaved(); }
catch (e) { setError(e.detail || e.message); }
setSaving(false);
};
const sourceLabel = ({ db: { c:'pill accent', l:'db' }, env: { c:'pill', l:'env' }, default: { c:'pill', l:'default' } })[s.source] || { c:'pill', l:s.source };
return (
{s.key}
{sourceLabel.l}
{s.type === 'secret' && {s.is_set ? 'set' : 'unset'} }
{s.requires_restart && restart }
{s.description}
{error &&
{error}
}
type: {s.type}
{s.type === 'bool' ? (
setVal(e.target.value)} style={{ width: 110 }}>
true false
) : s.type === 'secret' ? (
setVal(e.target.value)}
placeholder={s.is_set ? '(set — paste to replace)' : '(unset)'} style={{ width: 280, fontSize: 13 }}/>
) : (
setVal(e.target.value)} style={{ width: 220, fontSize: 13 }}/>
)}
Save
);
}
/* ---------- Exchange Balance (dedicated page) ---------- */
function ExchangeBalance() {
const [bal, { reload, loading }] = useApi('/api/admin/exchange/balance');
return (
<>
Admin · Exchange
Bybit account
Refresh
{loading && !bal &&
Loading…
}
{bal &&
}
{bal && bal.assets && bal.assets.length > 0 && (
Assets
Per-coin breakdown
Asset Wallet Available Margin Unrealized
{bal.assets.map(a => (
{a.asset}
{fmtNum(a.wallet_balance, 4)}
{fmtNum(a.available_balance, 4)}
{fmtNum(a.margin_balance, 4)}
= 0 ? 'var(--pos)' : 'var(--neg)', fontWeight: 600 }}>{a.unrealized_pnl >= 0 ? '+' : ''}{fmtNum(a.unrealized_pnl, 4)}
))}
)}
>
);
}
/* ---------- Admin Bot Detail (tabs: positions, trades, performance, scanner, config, keys) ---------- */
function AdminBotDetail({ botId, go }) {
const [bot] = useApi('/api/bots/' + botId);
const [tab, setTab] = React.useState('positions');
if (!bot) return Loading…
;
const color = colorFor(bot.id);
const flatten = async () => {
if (!confirm('Flatten all open positions at market?')) return;
try { const r = await api.post(`/api/admin/bots/${botId}/flatten`); alert(`Flattened ${r.closed_count} position(s)`); }
catch (e) { alert('Flatten failed: ' + (e.detail || e.message)); }
};
return (
<>
go('admin')} style={{ marginBottom: 8 }}>← Admin
{emojiFor(bot.risk_category)}
{bot.risk_category}
{bot.is_kill_switched ? 'KILLED' : bot.is_active ? 'Active' : 'Off'}
{bot.name}
Flatten Now
{['positions','trades','performance','scanner','config','keys'].map(t => (
setTab(t)} className={'btn ' + (tab === t ? 'soft' : 'ghost') + ' sm'} style={{ textTransform: 'capitalize', borderRadius: '12px 12px 0 0' }}>{t}
))}
{tab === 'positions' &&
}
{tab === 'trades' && }
{tab === 'performance' && }
{tab === 'scanner' && }
{tab === 'config' && }
{tab === 'keys' && }
>
);
}
function PositionsTab({ botId }) {
const [positions] = useApi(`/api/admin/bots/${botId}/positions`);
if (!positions) return Loading…
;
if (positions.length === 0) return No open positions
;
return (
ID Symbol Side Qty rem Entry Mark SL TP1/TP2 Unrealized Status
{positions.map(p => (
#{p.id}
{p.symbol}
{p.side}
{fmtNum(p.qty_remaining, 6)}
{p.entry_px != null ? fmtNum(p.entry_px, 4) : '—'}
{p.mark_price != null ? fmtNum(p.mark_price, 4) : '—'}
{fmtNum(p.sl_px, 4)}
{p.tp1_px != null ? fmtNum(p.tp1_px, 4) : '—'} / {p.tp2_px != null ? fmtNum(p.tp2_px, 4) : '—'}
= 0 ? 'var(--pos)' : 'var(--neg)', fontWeight: 600 }}>
{p.unrealized_pnl == null ? '—' : (p.unrealized_pnl >= 0 ? '+' : '') + fmtNum(p.unrealized_pnl, 4) + ' (' + (p.unrealized_pnl_pct != null ? p.unrealized_pnl_pct.toFixed(2) : '0.00') + '%)'}
{p.status}
{p.tp1_hit && TP1 Hit }
))}
);
}
function TradesTab({ botId }) {
const [trades] = useApi(`/api/admin/bots/${botId}/trades?limit=100`);
if (!trades) return Loading…
;
if (trades.length === 0) return No closed trades yet
;
return (
ID Symbol Side Qty Entry SL TP1 / TP2 Realized Fees Closed Reason
{trades.map(t => (
#{t.id}
{t.symbol}
{t.side}
{fmtNum(t.qty, 6)}
{t.entry_px != null ? fmtNum(t.entry_px, 4) : '—'}
{t.sl_px != null ? fmtNum(t.sl_px, 4) : '—'}
{t.tp1_px != null ? fmtNum(t.tp1_px, 4) : '—'} / {t.tp2_px != null ? fmtNum(t.tp2_px, 4) : '—'}
= 0 ? 'var(--pos)' : 'var(--neg)', fontWeight: 600 }}>{t.realized_pnl >= 0 ? '+' : ''}{fmtNum(t.realized_pnl, 4)}
{fmtNum(t.fees, 4)}
{t.closed_at ? fmtDateTime(t.closed_at) : '—'}
{t.close_reason || '—'}
{t.tp1_hit && TP1 Hit }
))}
);
}
function PerformanceTab({ botId, color }) {
const [bucket, setBucket] = React.useState('hour');
const [points] = useApi(`/api/admin/bots/${botId}/unit-price-history?bucket=${bucket}`, [bucket]);
const prices = (points || []).map(p => p.unit_price);
const last = prices.length ? prices[prices.length - 1] : null;
const first = prices.length ? prices[0] : null;
const peak = prices.length ? Math.max(...prices) : null;
const trough = prices.length ? Math.min(...prices) : null;
const change = last && first ? last - first : 0;
const changePct = first ? (change / first * 100) : 0;
return (
<>
Unit price
${last != null ? fmtNum(last, 4) : '—'}
{[['hour','1H'],['day','1D'],['week','1W'],['month','1M']].map(([b, lab]) => (
setBucket(b)}>{lab}
))}
= 0} neg={change < 0}/>
{prices.length > 0
?
: No data yet at this resolution. Equity loop writes one snapshot per minute when the worker is running.
}
>
);
}
function ScannerTab({ botId }) {
const [snap, { reload }] = useApi(`/api/admin/bots/${botId}/scanner`);
React.useEffect(() => {
const t = setInterval(reload, 15000);
return () => clearInterval(t);
}, [reload]);
if (!snap) return Loading…
;
if (!snap.last_scan_at) return No scan yet — worker may not be running, or first 3m candle hasn't closed since startup.
;
const ageS = Math.round((Date.now() - new Date(snap.last_scan_at).getTime()) / 1000);
return (
<>
Last scan
{snap.decision || '—'}
{ageS}s ago · trigger {snap.tick_symbol || '—'} · {snap.decision_reason || ''}
Candidates
Top {snap.top_candidates.length} by composite score
Symbol Side Score Trend Momentum Pullback Breakout S/R Vol Regime
{snap.top_candidates.map((c, i) => (
{c.symbol}
{c.side ? {c.side} : — }
= 0.7 ? 'var(--pos)' : (c.score ?? 0) >= 0.5 ? 'var(--warn)' : 'var(--ink-4)' }}>{(c.score ?? 0).toFixed(2)}
{(c.trend ?? 0).toFixed(2)}
{(c.momentum ?? 0).toFixed(2)}
{(c.pullback ?? 0).toFixed(2)}
{(c.breakout ?? 0).toFixed(2)}
{(c.sr ?? 0).toFixed(2)}
{(c.vol_regime ?? 0).toFixed(2)}
{c.regime_ok ? : }
))}
>
);
}
function ConfigTab({ botId }) {
const [cfg, { reload, error: loadErr }] = useApi(`/api/admin/bots/${botId}/config`);
const [draft, setDraft] = React.useState(null);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState(null);
React.useEffect(() => { if (cfg) setDraft({ ...cfg, symbols_str: JSON.stringify(cfg.symbols || []), sp_str: JSON.stringify(cfg.strategy_params || {}, null, 2), so_str: JSON.stringify(cfg.symbol_overrides || {}, null, 2) }); }, [cfg]);
if (loadErr) return Failed to load config
{loadErr.detail || loadErr.message || 'Unknown error'}
Retry ;
if (!draft) return Loading…
;
const save = async () => {
setSaving(true); setError(null);
try {
let symbols, sp, so;
try { symbols = JSON.parse(draft.symbols_str); } catch { setError('Symbols must be a JSON array'); setSaving(false); return; }
try { sp = JSON.parse(draft.sp_str); } catch { setError('Strategy params must be valid JSON'); setSaving(false); return; }
try { so = JSON.parse(draft.so_str || '{}'); } catch { setError('Symbol overrides must be valid JSON'); setSaving(false); return; }
const body = {
symbols, leverage: Number(draft.leverage),
risk_per_trade_pct: Number(draft.risk_per_trade_pct),
max_concurrent_positions: Number(draft.max_concurrent_positions),
max_position_notional_pct: Number(draft.max_position_notional_pct),
htf_interval: draft.htf_interval, ltf_interval: draft.ltf_interval,
daily_loss_pct: Number(draft.daily_loss_pct),
weekly_dd_pct: Number(draft.weekly_dd_pct),
cooldown_seconds: Number(draft.cooldown_seconds),
liq_buffer_pct: Number(draft.liq_buffer_pct),
paper_mode: !!draft.paper_mode, enabled: !!draft.enabled,
strategy_params: sp,
symbol_overrides: so,
};
await api.put(`/api/admin/bots/${botId}/config`, body); reload();
} catch (e) { setError(e.detail || e.message); }
setSaving(false);
};
const setF = (k, v) => setDraft({ ...draft, [k]: v });
return (
);
}
function KeysTab({ botId }) {
const [status, { reload, error: loadErr }] = useApi(`/api/admin/bots/${botId}/keys`);
const [apiKey, setApiKey] = React.useState('');
const [secret, setSecret] = React.useState('');
const [testnet, setTestnet] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState(null);
React.useEffect(() => { if (status) setTestnet(status.is_testnet ?? true); }, [status]);
if (loadErr) return Failed to load keys status
{loadErr.detail || loadErr.message || 'Unknown error'}
Retry ;
if (!status) return Loading…
;
const save = async () => {
if (!apiKey || !secret) { setError('Both key and secret are required'); return; }
if (!testnet && !confirm('You are saving MAINNET keys. Live trading uses real money. Continue?')) return;
setSaving(true); setError(null);
try { await api.put(`/api/admin/bots/${botId}/keys`, { api_key: apiKey, api_secret: secret, is_testnet: testnet }); reload(); setApiKey(''); setSecret(''); }
catch (e) { setError(e.detail || e.message); }
setSaving(false);
};
return (
<>
Keys set
{status.has_keys ? : }{status.has_keys ? 'Yes' : 'No'}
Environment
{status.is_testnet ? 'TESTNET' : 'MAINNET'}
Subaccount
{status.subaccount_label || '—'}
Secrets encrypted at rest with Fernet. The platform never echoes the secret back.
>
);
}
/* ---------- Users (admin) ---------- */
function Users() {
const [filter, setFilter] = React.useState('all'); // all | pending | approved | rejected
const path = filter === 'all' ? '/api/admin/users' : `/api/admin/users?approval_status=${filter}`;
const [list, { reload }] = useApi(path, [filter]);
const [creating, setCreating] = React.useState(false);
const [editing, setEditing] = React.useState(null);
const [adjusting, setAdjusting] = React.useState(null);
const [pwReset, setPwReset] = React.useState(null);
const approve = async (u) => { await api.post(`/api/admin/users/${u.id}/approve`); reload(); };
const reject = async (u) => {
if (!confirm(`Reject ${u.username}? They won't be able to sign in.`)) return;
await api.post(`/api/admin/users/${u.id}/reject`); reload();
};
return (
<>
Admin · Users
User management · {list ? list.length : 0}
{['all','pending','approved','rejected'].map(t => (
setFilter(t)} style={{ textTransform: 'capitalize' }}>{t}
))}
setCreating(true)}> Add user
{!list ?
Loading…
: (
User Role Approval Active Holdings Cash Created
{list.map(u => {
const approvalPill =
u.approval_status === 'approved' ? 'pos' :
u.approval_status === 'pending' ? 'warn' :
'neg';
return (
{(u.full_name || u.username).split(' ').map(s => s[0]).join('').slice(0,2).toUpperCase()}
{u.full_name || u.username}
{u.email}
{u.ktp_number && (
KTP: {u.ktp_full_name} ({u.ktp_number})
)}
{u.role}
{u.approval_status}
{u.is_active ? 'Active' : 'Disabled'}
{u.holdings_count}
{USD(u.cash_balance)}
{fmtDate(u.created_at)}
{u.approval_status === 'pending' && (
<>
approve(u)}> Approve
reject(u)}> Reject
>
)}
{u.approval_status !== 'pending' && (
<>
setEditing(u)}>Edit
setAdjusting(u)}>Cash
setPwReset(u)} title="Reset password">
>
)}
);
})}
{list.length === 0 && No users{filter !== 'all' ? ' in ' + filter : ''}. }
)}
{creating && setCreating(false)} onSaved={() => { reload(); setCreating(false); }}/>}
{editing && setEditing(null)} onSaved={() => { reload(); setEditing(null); }}/>}
{adjusting && setAdjusting(null)} onSaved={() => { reload(); setAdjusting(null); }}/>}
{pwReset && setPwReset(null)} onSaved={() => setPwReset(null)}/>}
>
);
}
function ModalShell({ title, sub, onClose, children }) {
return (
e.stopPropagation()}>
{children}
);
}
function UserCreateModal({ onClose, onSaved }) {
const [form, setForm] = React.useState({ username: '', email: '', password: '', full_name: '', role: 'user', is_active: true });
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const set = (k, v) => setForm({ ...form, [k]: v });
const submit = async () => {
setBusy(true); setError(null);
try { await api.post('/api/admin/users', form); onSaved(); }
catch (e) { setError(e.detail || e.message); }
setBusy(false);
};
return (
Username set('username', e.target.value)}/>
Email set('email', e.target.value)}/>
Full name set('full_name', e.target.value)}/>
Password set('password', e.target.value)}/>
Role
set('role', e.target.value)}>
user admin
set('is_active', e.target.checked)}/> Active
{error && {error}
}
{busy ? 'Creating…' : 'Create user'}
);
}
function UserEditModal({ user, onClose, onSaved }) {
const [form, setForm] = React.useState({
email: user.email,
full_name: user.full_name || '',
role: user.role,
is_active: user.is_active,
ktp_number: user.ktp_number || '',
ktp_full_name: user.ktp_full_name || '',
});
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const set = (k, v) => setForm({ ...form, [k]: v });
const submit = async () => {
setBusy(true); setError(null);
try { await api.put(`/api/admin/users/${user.id}`, form); onSaved(); }
catch (e) { setError(e.detail || e.message); }
setBusy(false);
};
return (
Email set('email', e.target.value)}/>
Full name set('full_name', e.target.value)}/>
NIK (KTP number) set('ktp_number', e.target.value)} placeholder="16-digit national ID"/>
KTP Full Name set('ktp_full_name', e.target.value)} placeholder="Name exactly as printed on KTP"/>
Role
set('role', e.target.value)}>
user admin
set('is_active', e.target.checked)}/> Active
{error && {error}
}
{busy ? 'Saving…' : 'Save changes'}
);
}
function PasswordResetModal({ user, onClose, onSaved }) {
const [pw, setPw] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const submit = async () => {
setBusy(true); setError(null);
try { await api.post(`/api/admin/users/${user.id}/reset-password`, { password: pw }); alert('Password reset.'); onSaved(); }
catch (e) { setError(e.detail || e.message); }
setBusy(false);
};
return (
New password setPw(e.target.value)}/>
User will need to sign in with this password next time. They aren't notified — share it through your own channel.
{error && {error}
}
{busy ? 'Saving…' : 'Set password'}
);
}
function UserCashModal({ user, onClose, onSaved }) {
const [delta, setDelta] = React.useState(0);
const [note, setNote] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const submit = async () => {
setBusy(true); setError(null);
try { await api.post(`/api/admin/users/${user.id}/cash`, { delta: Number(delta), note }); onSaved(); }
catch (e) { setError(e.detail || e.message); }
setBusy(false);
};
return (
Note (optional) setNote(e.target.value)} placeholder="e.g. off-channel deposit, correction"/>
After adjustment {USD(Number(user.cash_balance) + Number(delta || 0))}
{error && {error}
}
{busy ? 'Saving…' : 'Apply adjustment'}
);
}
/* ---------- Stats (historical trade review) ---------- */
function Stats() {
// No GET /api/admin/bots exists — admins use the user-facing /api/bots list
// (same data, just no per-bot internal state). All admins are auth'd.
const [bots] = useApi('/api/bots');
const [botId, setBotId] = React.useState(null);
React.useEffect(() => {
if (botId == null && bots && bots.length) setBotId(bots[0].id);
}, [bots]);
// Range state: which time window to slice by. Defaults to inception so
// the page loads with the full picture; admins drill down via presets or
// milestone anchors.
const [range, setRange] = React.useState({ kind: 'preset', value: 'inception' });
const [showAdd, setShowAdd] = React.useState(false);
const [milestones, milestoneActions] = useApi(
botId ? `/api/admin/bots/${botId}/milestones` : null, [botId]);
// Map range → ISO `since` string sent to the stats endpoint.
const since = React.useMemo(() => {
if (range.kind === 'preset') {
if (range.value === 'inception') return null;
const ms = { '24h': 86400e3, '7d': 7*86400e3, '30d': 30*86400e3 }[range.value];
return ms ? new Date(Date.now() - ms).toISOString() : null;
}
if (range.kind === 'milestone') {
const m = (milestones || []).find(x => x.id === range.id);
return m ? m.ts : null;
}
if (range.kind === 'custom') return range.since || null;
return null;
}, [range, milestones]);
const statsUrl = botId
? `/api/admin/bots/${botId}/stats${since ? '?since=' + encodeURIComponent(since) : ''}`
: null;
const [s, { loading }] = useApi(statsUrl, [statsUrl]);
const addMilestone = async (label, description) => {
await api.post(`/api/admin/bots/${botId}/milestones`, { label, description });
milestoneActions.reload();
setShowAdd(false);
};
const removeMilestone = async (id) => {
if (!confirm('Delete this milestone?')) return;
await api.del(`/api/admin/bots/${botId}/milestones/${id}`);
milestoneActions.reload();
// If we were filtering by the milestone we just removed, fall back.
if (range.kind === 'milestone' && range.id === id) {
setRange({ kind: 'preset', value: 'inception' });
}
};
return (
<>
{(bots || []).map(b => (
setBotId(b.id)}>{b.name}
))}
setShowAdd(true)}
onRemoveMilestone={removeMilestone}/>
{showAdd && setShowAdd(false)}/>}
{loading && Loading…
}
{s && s.total_trades === 0 && No closed trades in this window.
}
{s && s.total_trades > 0 && }
>
);
}
function StatsRangeSelector({ range, setRange, milestones, onAddClick, onRemoveMilestone }) {
const presets = [['24h', '24h'], ['7d', '7d'], ['30d', '30d'], ['inception', 'Since inception']];
const isPreset = (k) => range.kind === 'preset' && range.value === k;
const isMilestone = (id) => range.kind === 'milestone' && range.id === id;
const isCustom = range.kind === 'custom';
const onCustomDate = (e) => {
const v = e.target.value;
if (!v) return setRange({ kind: 'preset', value: 'inception' });
// gives YYYY-MM-DD; treat as local midnight.
setRange({ kind: 'custom', since: new Date(v).toISOString() });
};
const customValue = isCustom && range.since ? range.since.slice(0, 10) : '';
return (
Range
{presets.map(([k, lab]) => (
setRange({ kind: 'preset', value: k })}>{lab}
))}
or
+ Mark milestone
{milestones.length > 0 && (
Milestones
{milestones.map(m => (
setRange({ kind: 'milestone', id: m.id })}
title={m.description || ''}>
{m.label}· {new Date(m.ts).toLocaleDateString()}
onRemoveMilestone(m.id)} title="Delete"
style={{ background: 'transparent', border: 0, color: 'inherit', opacity: 0.5, cursor: 'pointer', padding: '0 4px', fontSize: 14, lineHeight: 1 }}>×
))}
)}
);
}
function AddMilestoneInline({ onSave, onCancel }) {
const [label, setLabel] = React.useState('');
const [desc, setDesc] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState(null);
const save = async () => {
if (!label.trim()) return;
setBusy(true); setError(null);
try { await onSave(label.trim(), desc.trim() || null); }
catch (e) { setError(e.detail || e.message || 'Save failed'); }
setBusy(false);
};
return (
{error &&
{error}
}
Marks NOW as a named anchor. The Range row will list this for quick filtering.
);
}
function StatsBody({ s }) {
const cumPnls = (s.equity_curve || []).map(p => p.cum_pnl);
return (
<>
{/* Headline KPIs */}
Total P&L
= 0 ? 'var(--pos)' : 'var(--neg)' }}>
{USD(s.total_pnl, { sign: true })}
{s.total_trades} closed trades
Win rate
{(s.win_rate_pct ?? 0).toFixed(1)}%
{s.wins} W · {s.losses} L · {s.breakevens} BE
Profit factor
{(s.profit_factor ?? 0) >= 99999 ? '∞' : (s.profit_factor ?? 0).toFixed(2)}
Σ wins / |Σ losses|
Expectancy
= 0 ? 'var(--pos)' : 'var(--neg)' }}>
{USD(s.expectancy_per_trade, { sign: true })}
avg P&L per trade
{/* Equity curve + secondary KPIs */}
Cumulative P&L curve
= 0 ? 'var(--pos)' : 'var(--neg)'}/>
since {s.equity_curve.length ? new Date(s.equity_curve[0].ts).toISOString().slice(0,10) : '—'}
{cumPnls.length > 1
?
= 0 ? 'var(--pos)' : 'var(--neg)'} height={260}/>
: Not enough data yet.
}
Avg loss
{USD(s.avg_loss)}
Avg hold
{(s.avg_hold_minutes ?? 0).toFixed(1)} min
{/* Best / worst */}
Best trade
{s.best_trade && (
{s.best_trade.symbol}
{s.best_trade.side}
{USD(s.best_trade.pnl, { sign: true })}
trade #{s.best_trade.id}
)}
Worst trade
{s.worst_trade && (
{s.worst_trade.symbol}
{s.worst_trade.side}
{USD(s.worst_trade.pnl, { sign: true })}
trade #{s.worst_trade.id}
)}
{/* Group tables */}
By symbol
Sorted by total P&L
Symbol N Win % Avg P&L Total
{s.by_symbol.map(g => (
{g.key}
{g.n}
{(g.win_rate_pct ?? 0).toFixed(0)}%
= 0 ? 'var(--pos)' : 'var(--neg)' }}>{USD(g.avg_pnl, { sign: true })}
= 0 ? 'var(--pos)' : 'var(--neg)', fontWeight: 600 }}>{USD(g.pnl, { sign: true })}
))}
By close reason
Includes errored rows so failure modes show up
Reason N Win % Total
{s.by_close_reason.map(g => (
{g.key}
{g.n}
{g.n ? (g.win_rate_pct ?? 0).toFixed(0) : 0}%
= 0 ? 'var(--pos)' : 'var(--neg)', fontWeight: 600 }}>{USD(g.pnl, { sign: true })}
))}
>
);
}
// ---------------------- Funding panel (Phase B) ----------------------
function FundingPanel() {
const [data, setData] = React.useState(null);
const [err, setErr] = React.useState(null);
const [loading, setLoading] = React.useState(false);
const [refreshedAt, setRefreshedAt] = React.useState(null);
const fetchData = React.useCallback(async () => {
setLoading(true); setErr(null);
try {
const d = await api.get('/api/admin/funding?lookback=90&samples=30');
setData(d);
setRefreshedAt(new Date());
} catch (e) {
setErr(e.detail || e.message || 'fetch failed');
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
let alive = true;
fetchData();
const t = setInterval(() => { if (alive) fetchData(); }, 5 * 60 * 1000);
return () => { alive = false; clearInterval(t); };
}, [fetchData]);
const rows = data?.symbols || [];
return (
<>
Admin · Funding
Funding rates
{refreshedAt && Updated {refreshedAt.toLocaleTimeString()} }
{loading ? '...' : 'Refresh'}
Per-symbol Bybit V5 funding rate. Z-score is computed over the last 90 samples (~30 days
at 3 samples/day). Flagged red when |z| > 2 — that's a sign the perpetual crowd is
heavily one-sided, which often precedes a mean-reversion against the consensus.
{err &&
Error: {err}
}
{rows.length === 0 && !err && !loading && (
No funding rate data yet. Make sure FUNDING_RATE_ENABLED=1 is set on the
worker container, and that it has had time to do its first poll (it polls 5 min past
each 8h funding cycle — 00:05, 08:05, 16:05 UTC).
)}
{rows.length > 0 && (
Symbol
Latest rate
Last paid
Z-score (90d)
Samples
Trend (last 30)
Signal
{rows.map(s => {
const r = s.latest_rate;
const z = s.z_score;
const extreme = z != null && Math.abs(z) > 2;
const sparkData = (s.sparkline || []).map(p => p.r);
const lastT = s.latest_time ? new Date(s.latest_time) : null;
const ageH = lastT ? Math.round((Date.now() - lastT.getTime()) / 3600000) : null;
return (
{s.symbol}
0 ? 'var(--pos)' : r < 0 ? 'var(--neg)' : 'inherit' }}>
{(r * 100).toFixed(4)}%
{ageH != null ? ageH + 'h ago' : '—'}
{z == null ? collecting :
extreme ? z={z.toFixed(2)} :
z.toFixed(2)}
{s.sample_count}
{sparkData.length >= 2 ? (
0 ? 'var(--pos)' : 'var(--neg)'} fill={false} height={32}/>
) : — }
{z == null ? '—' :
z > 2 ? Longs over-paying — fade-short candidate :
z < -2 ? Shorts over-paying — fade-long candidate :
Within normal range }
);
})}
)}
>
);
}
// ---------------------- Backtest panel (Phase C) ----------------------
function BacktestPanel() {
const [jobs, setJobs] = React.useState([]);
const [selectedId, setSelectedId] = React.useState(null);
const [detail, setDetail] = React.useState(null);
const [form, setForm] = React.useState({
symbol: 'BTCUSDT',
start: '2024-01-01',
end: new Date().toISOString().slice(0, 10),
ltf: '15m', ltf15: '15m', htf1: '1h', htf4: '4h',
equity: 10000,
risk: 0.01,
});
const [submitting, setSubmitting] = React.useState(false);
const [submitErr, setSubmitErr] = React.useState(null);
const refreshJobs = React.useCallback(async () => {
try {
const j = await api.get('/api/admin/backtests?limit=30');
setJobs(j);
} catch (e) {
console.error('list jobs failed', e);
}
}, []);
React.useEffect(() => {
refreshJobs();
const t = setInterval(refreshJobs, 3000);
return () => clearInterval(t);
}, [refreshJobs]);
React.useEffect(() => {
if (selectedId == null) { setDetail(null); return; }
let alive = true;
const load = async () => {
try {
const d = await api.get('/api/admin/backtests/' + selectedId);
if (alive) setDetail(d);
} catch (e) {
if (alive) setDetail({ error: e.detail || 'load failed' });
}
};
load();
// Poll while running/queued
const t = setInterval(() => {
if (detail && (detail.status === 'done' || detail.status === 'failed' || detail.status === 'cancelled')) return;
load();
}, 3000);
return () => { alive = false; clearInterval(t); };
}, [selectedId, detail?.status]);
const submitJob = async (e) => {
e?.preventDefault();
setSubmitting(true); setSubmitErr(null);
try {
const job = await api.post('/api/admin/backtests', form);
await refreshJobs();
setSelectedId(job.id);
} catch (e) {
setSubmitErr(e.detail || e.message || 'submit failed');
} finally {
setSubmitting(false);
}
};
const setF = (k) => (e) => setForm({ ...form, [k]: e.target.value });
const setFNum = (k) => (e) => setForm({ ...form, [k]: parseFloat(e.target.value) });
const cancelJob = async (id) => {
if (!confirm('Cancel job ' + id + '?')) return;
try {
await api.del('/api/admin/backtests/' + id);
await refreshJobs();
if (selectedId === id) setSelectedId(null);
} catch (e) { alert(e.detail || 'cancel failed'); }
};
return (
<>
Admin · Backtest
Strategy backtester
{/* Submit form */}
{/* Jobs list + detail */}
{!selectedId && (
Recent jobs
Live · auto-refresh 3s
{jobs.length === 0 ? (
No backtests yet — submit one on the left.
) : (
ID Symbol Range Status
Sharpe CAGR Trades
{jobs.map(j => (
setSelectedId(j.id)}>
#{j.id}
{j.symbol || '—'}
{j.start || '—'} → {j.end || '—'}
{j.status === 'running' && }
{j.sharpe != null ? j.sharpe.toFixed(2) : '—'}
{j.cagr != null ? (j.cagr * 100).toFixed(1) + '%' : '—'}
{j.trades ?? '—'}
{(j.status === 'queued' || j.status === 'running') && (
{ e.stopPropagation(); cancelJob(j.id); }}>Cancel
)}
))}
)}
)}
{selectedId && (
setSelectedId(null)}
onDelete={() => cancelJob(selectedId)}
/>
)}
>
);
}
function BTField({ label, children, style = {} }) {
return (
);
}
function BTProgressBar({ pct }) {
const v = Math.max(0, Math.min(100, pct || 0));
return (
);
}
function BTStatusPill({ status }) {
const map = {
queued: { tone: 'warn', label: 'Queued' },
running: { tone: 'pos', label: 'Running' },
done: { tone: 'pos', label: 'Done' },
failed: { tone: 'neg', label: 'Failed' },
cancelled: { tone: 'muted', label: 'Cancelled' },
};
const { tone, label } = map[status] || { tone: 'muted', label: status };
return {label} ;
}
function BacktestDetailView({ detail, onBack, onDelete }) {
if (!detail) return Loading…
;
if (detail.error) return {detail.error}
;
const m = detail.metrics || {};
const p = detail.params || {};
const eqCurve = (detail.equity_curve || []).map(e => e.eq);
return (
← Back to list
Job #{detail.id} · {p.symbol} ·
{p.start} → {p.end} · {p.ltf}/{p.ltf15}/{p.htf1}/{p.htf4} · equity ${p.equity} · risk {(p.risk * 100).toFixed(1)}%
{detail.status === 'failed' && (
Job failed
{detail.error_message}
)}
{detail.status === 'queued' && (
Waiting for the backtester sidecar to pick up this job…
)}
{detail.status === 'running' && (
Running simulation in a subprocess — typically 1–3 minutes depending on date range.
)}
{detail.status === 'done' && (
<>
= 0 ? 'pos' : 'neg'}/>
= 0 ? 'pos' : 'neg'}/>
{eqCurve.length >= 2 && (
)}
{detail.trades?.length > 0 && (
Trade ledger ({detail.trades.length} trades, first 50)
Entry Exit Side Entry
Stop R PnL% Reason
{detail.trades.slice(0, 50).map((t, i) => (
{t.entry_time?.slice(0, 16).replace('T', ' ')}
{t.exit_time?.slice(0, 16).replace('T', ' ')}
{t.side}
{fmtNum(t.entry_px, 2)}
{fmtNum(t.stop_px, 2)}
= 0 ? 'var(--pos)' : 'var(--neg)' }}>{t.realized_r?.toFixed(2)}
{(t.realized_pnl_pct * 100)?.toFixed(2)}%
{t.exit_reason}
))}
)}
>
)}
);
}
function BTMetric({ label, value, tone }) {
const color = tone === 'pos' ? 'var(--pos)' : tone === 'neg' ? 'var(--neg)' : 'inherit';
return (
);
}
Object.assign(window, { AdminGodView, Approvals, KillSwitch, Settings, ExchangeBalance, AdminBotDetail, Users, Stats, FundingPanel, BacktestPanel });