'use client' import { useState, useEffect, useCallback } from 'react' import { useLocale } from 'next-intl' import { useAccount, useConnect, useSignMessage } from 'wagmi' import { getUserPublic, getUser, getTelegramStatus, setUserSettings, setHlApiKey, setManualWindow, setAutoTrade, subscribe, type UserSettings, } from '@/lib/api' import { useDashboardStore } from '@/store/dashboard' import { signRequest, getOrCreateViewEnvelope, getCachedViewEnvelope, } from '@/lib/signedRequest' import { walletErrorLabel } from '@/lib/walletError' import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect' import { confirmSign } from '@/components/wallet/SignConfirmSheet' const DEFAULT_SETTINGS: UserSettings = { leverage: 3, position_size_usd: 20, take_profit_pct: 2, stop_loss_pct: 1.5, min_confidence: 70, daily_budget_usd: 15, sys2_leverage: 2, sys2_mode: 'standard', active_from: null, active_until: null, trump_enabled: false, macro_enabled: false, } // The backend treats active_from/active_until as a DAILY RECURRING time window // (it extracts only .time() and compares with the current UTC time). Storing a // full datetime-local value was misleading — the date part was silently ignored // so users thought they were setting a date range when they were setting a // time-of-day schedule. We now use type="time" and store as a fixed-epoch ISO // so the backend's .time() extraction gives the right HH:MM:SS. function isoToLocalInput(iso: string | null): string { if (!iso) return '' const d = new Date(iso) if (isNaN(d.getTime())) return '' const pad = (n: number) => String(n).padStart(2, '0') // Return just HH:MM for the time input return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}` } function localInputToIso(v: string): string | null { if (!v) return null // v is HH:MM from . Store as 2000-01-01T{HH:MM}:00Z so // the backend's .time() extraction returns the correct UTC time. const [hh, mm] = v.split(':') if (!hh || !mm) return null return `2000-01-01T${hh.padStart(2,'0')}:${mm.padStart(2,'0')}:00Z` } export default function BotConfigPanel() { useLocale() // keep next-intl context hydrated even though i18n is shelved const { address, isConnected } = useAccount() const { connectAsync, connectors } = useConnect() const { signMessageAsync } = useSignMessage() const { isSubscribed, hlApiKeySet, hlApiKeyMasked, setSubscribed, setBotReadiness, setHlApiKeySet, setPaperMode: setPaperModeStore, } = useDashboardStore() const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [tpConfigured, setTpConfigured] = useState(false) const [slConfigured, setSlConfigured] = useState(false) const [useBudget, setUseBudget] = useState(false) const [useSchedule, setUseSchedule] = useState(false) const [fromLocal, setFromLocal] = useState('') const [untilLocal, setUntilLocal] = useState('') const [dirty, setDirty] = useState(false) const [saveState, setSaveState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle') const [saveErr, setSaveErr] = useState('') const [apiKey, setApiKey] = useState('') const [keyState, setKeyState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle') const [keyErr, setKeyErr] = useState('') const [subState, setSubState] = useState<'idle'|'signing'|'saving'|'err'>('idle') const [subErr, setSubErr] = useState('') const [subPaperChoice, setSubPaperChoice] = useState<'paper' | 'live'>('paper') const [manualUntil, setManualUntil] = useState(null) const [mwState, setMwState] = useState<'idle'|'signing'|'saving'|'err'>('idle') const [mwErr, setMwErr] = useState('') const [mwTick, setMwTick] = useState(0) const [paperMode, setPaperMode] = useState(false) const [autoTrade, setAutoTrade_] = useState(false) const [atState, setAtState] = useState<'idle'|'signing'|'saving'|'err'>('idle') const [showAdvanced, setShowAdvanced] = useState(false) const [mounted, setMounted] = useState(false) const [loadState, setLoadState] = useState<'idle'|'loading'|'loaded'|'err'>('idle') const [loadErr, setLoadErr] = useState('') const [connectErr, setConnectErr] = useState('') // Telegram binding state. Macro Vibes (System-2) is manage-only via the // Telegram /adopt command, so a Macro-only user who hasn't bound Telegram // cannot actually hand any position to the bot — readiness must reflect that. // The unauthenticated status call returns only { bound } (no chat_id), which // is all we need here. const [tgBound, setTgBound] = useState(null) useEffect(() => { setMounted(true) }, []) useEffect(() => { if (!address) { setTgBound(null); return } let cancelled = false getTelegramStatus(address.toLowerCase()) .then(s => { if (!cancelled) setTgBound(!!s.bound) }) .catch(() => { if (!cancelled) setTgBound(null) }) return () => { cancelled = true } }, [address]) // B33: when the connected wallet changes, immediately wipe all private // settings so the previous wallet's config never leaks into the new one. useEffect(() => { setSettings(DEFAULT_SETTINGS) setPaperMode(false) setAutoTrade_(false) setTpConfigured(false) setSlConfigured(false) setUseBudget(false) setUseSchedule(false) setFromLocal('') setUntilLocal('') setManualUntil(null) setApiKey('') setDirty(false) setSaveState('idle'); setSaveErr('') setKeyState('idle'); setKeyErr('') setSubState('idle'); setSubErr('') setLoadState('idle'); setLoadErr('') setConnectErr('') }, [address]) function applyUserPayload(u: { active: boolean hl_api_key_set: boolean hl_api_key_masked: string | null paper_mode?: boolean auto_trade?: boolean settings: UserSettings manual_window_until?: string | null }) { setSubscribed(u.active) setPaperMode(!!u.paper_mode) setPaperModeStore(!!u.paper_mode) // sync to global store for B40/B41 setAutoTrade_(!!u.auto_trade) setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined) setManualUntil(u.manual_window_until ?? null) if (u.settings) { const s = u.settings setTpConfigured(s.take_profit_pct != null) setSlConfigured(s.stop_loss_pct != null) const hasBudget = s.daily_budget_usd != null setUseBudget(hasBudget) setBotReadiness(u.hl_api_key_set ? 'saved' : 'unknown') setSettings({ ...s, take_profit_pct: s.take_profit_pct ?? 2, stop_loss_pct: s.stop_loss_pct ?? 1.5, daily_budget_usd: s.daily_budget_usd ?? 15, trump_enabled: s.trump_enabled ?? false, macro_enabled: s.macro_enabled ?? false, }) const hasSched = s.active_from != null && s.active_until != null setUseSchedule(hasSched) setFromLocal(isoToLocalInput(s.active_from)) setUntilLocal(isoToLocalInput(s.active_until)) } } useEffect(() => { if (!mounted || !isConnected || !address) return let cancelled = false ;(async () => { const pub = await getUserPublic(address.toLowerCase()).catch(() => null) if (!pub || cancelled) return setSubscribed(pub.active) setHlApiKeySet(pub.hl_api_key_set) setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown') setAutoTrade_(!!pub.auto_trade) if (!pub.active) return const cached = getCachedViewEnvelope('view_user', address) if (!cached) return const u = await getUser(address, cached).catch(() => null) if (!u || cancelled) return applyUserPayload(u) setLoadState('loaded') })() return () => { cancelled = true } }, [address, isConnected, mounted]) // eslint-disable-line react-hooks/exhaustive-deps async function handleLoadSettings() { if (!address) return setLoadErr(''); setLoadState('loading') try { // Read-only identity check — go straight to MetaMask, no pre-confirm sheet. // getOrCreateViewEnvelope caches the result for 4 min so repeat visits are free. const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync }) const u = await getUser(address, env) applyUserPayload(u) setLoadState('loaded') } catch (e: unknown) { setLoadErr(walletErrorLabel(e, 'Cancelled', 120)) setLoadState('err') } } async function refreshUserState(wallet: string) { const pub = await getUserPublic(wallet.toLowerCase()) setSubscribed(pub.active) setHlApiKeySet(pub.hl_api_key_set) setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown') setAutoTrade_(!!pub.auto_trade) return pub } async function flipAutoTrade(on: boolean) { if (!address || atState !== 'idle') return const ok = await confirmSign({ label: on ? 'Enable Auto-Trade' : 'Disable Auto-Trade', description: on ? 'Bot will open real positions on Hyperliquid when qualifying Trump signals fire. Stop-loss and de-risking remain active.' : 'Bot stops opening new trades. Risk controls on existing positions keep running.', danger: on, }) if (!ok) return setAtState('signing') try { const env = await signRequest({ action: 'set_auto_trade', wallet: address, body: { enabled: on }, signMessageAsync }) setAtState('saving') const r = await setAutoTrade(env, on) setAutoTrade_(r.auto_trade) setAtState('idle') } catch (e: unknown) { console.error('set_auto_trade failed', e) setAtState('err') setTimeout(() => setAtState('idle'), 3000) } } function updateSettings(patch: Partial) { setSettings(s => ({ ...s, ...patch })) setDirty(true) } async function handleSubscribe() { if (!address) return const paperMode = subPaperChoice === 'paper' const ok = await confirmSign({ label: paperMode ? 'Start in paper mode' : 'Start live trading', description: paperMode ? 'Trades are simulated end-to-end — no funds are at risk. You can switch to live at any time from the settings panel.' : 'Start live trading. You\'ll need to add a Hyperliquid API key (next step) before the bot can open any real position.', danger: !paperMode, }) if (!ok) return setSubErr(''); setSubState('signing') try { const signedBody = paperMode ? { paper_mode: true } : null const env = await signRequest({ action: 'subscribe', wallet: address, body: signedBody, signMessageAsync }) setSubState('saving') await subscribe(env, { paper_mode: paperMode }) setSubscribed(true) setPaperMode(paperMode) void refreshUserState(address) setSubState('idle') setTpConfigured(false); setSlConfigured(false) setUseBudget(false); setLoadState('loaded') } catch (e: unknown) { setSubErr(walletErrorLabel(e, 'Cancelled', 100)) setSubState('err') } } async function handleSaveSettings() { if (!address) return const ok = await confirmSign({ label: 'Save trading settings', description: 'Applied to all new trades going forward. Open positions are not affected.', }) if (!ok) return setSaveErr(''); setSaveState('signing') try { let schedFrom: string | null = null let schedUntil: string | null = null if (useSchedule) { schedFrom = localInputToIso(fromLocal) schedUntil = localInputToIso(untilLocal) if (!schedFrom || !schedUntil) { setSaveErr('Pick both a start and end time'); setSaveState('err'); return } // Cross-midnight windows (e.g. 22:00–02:00) are VALID — the backend's // _is_in_active_window treats af_t > au_t as a wrap-around window // (now >= from OR now <= until). Only reject when start === end, which // would describe a zero-length (or full-day-ambiguous) window. if (new Date(schedUntil).getTime() === new Date(schedFrom).getTime()) { setSaveErr('Start and end can’t be the same time'); setSaveState('err'); return } } const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd if (trumpOn) { if (tp == null || tp < 0.1 || tp > 50) { setSaveErr('Take profit must be 0.1–50%'); setSaveState('err'); return } if (sl == null || sl < 0.1 || sl > 50) { setSaveErr('Stop loss must be 0.1–50%'); setSaveState('err'); return } } if (useBudget && (bd == null || bd <= 0 || bd > 100000)) { setSaveErr('Daily budget must be > $0'); setSaveState('err'); return } const payload: UserSettings = { ...settings, daily_budget_usd: useBudget ? bd : null, active_from: schedFrom, active_until: schedUntil } const env = await signRequest({ action: 'set_settings', wallet: address, body: payload, signMessageAsync }) setSaveState('saving') await setUserSettings(env, payload) setSettings(payload) setTpConfigured(tp != null); setSlConfigured(sl != null) setBotReadiness('saved'); setDirty(false) setSaveState('ok') setTimeout(() => setSaveState('idle'), 2500) } catch (e: unknown) { setSaveErr(walletErrorLabel(e, 'Cancelled', 100)) setSaveState('err') } } async function handleSaveKey() { if (!address || !apiKey.trim()) return const trimmed = apiKey.trim() if (!trimmed.startsWith('0x') || trimmed.length !== 66) { setKeyErr('Must be 0x + 64 hex chars'); setKeyState('err'); return } const ok = await confirmSign({ label: 'Link Hyperliquid API key', description: 'Stored encrypted on the server. Used by the bot to open and close positions only — no withdrawal access.', danger: true, }) if (!ok) return setKeyErr(''); setKeyState('signing') try { const env = await signRequest({ action: 'set_hl_api_key', wallet: address, body: { api_key: trimmed }, signMessageAsync }) setKeyState('saving') const res = await setHlApiKey(env, trimmed) setHlApiKeySet(true, res.masked_key) setBotReadiness('saved') void refreshUserState(address) setApiKey(''); setKeyState('ok') } catch (e: unknown) { setKeyErr(walletErrorLabel(e, 'Cancelled', 100)) setKeyState('err') } } const handleConnectWallet = useCallback(async () => { setConnectErr('') try { const connector = await getFirstReadyConnector(connectors) if (!connector) { setConnectErr('No wallet connector is available right now.'); return } await connectAsync({ connector }) } catch (err: unknown) { setConnectErr(walletConnectErrorLabel(err)) } }, [connectAsync, connectors]) async function handleManualWindow(hours: number) { if (!address) return const ok = await confirmSign(hours === 0 ? { label: 'Cancel override window', description: 'The bot will stop accepting new trades from this override. Any open position risk controls (stop-loss, de-risk) remain active.', } : { label: `Open ${hours}h override window`, description: `The bot will accept qualifying signals for the next ${hours} hour${hours > 1 ? 's' : ''}, regardless of your trading schedule. Use this for high-conviction catalysts (e.g. CPI, FOMC). Stop-loss is always active.`, danger: true, } ) if (!ok) return setMwErr(''); setMwState('signing') try { const env = await signRequest({ action: 'set_manual_window', wallet: address, body: { hours }, signMessageAsync }) setMwState('saving') const r = await setManualWindow(env, hours) setManualUntil(r.manual_window_until); setMwState('idle') } catch (e: unknown) { setMwErr(walletErrorLabel(e, 'Cancelled', 100)) setMwState('err') } } async function handleUpgradeToLive() { if (!address) return const ok = await confirmSign({ label: 'Switch to live trading', description: 'Switches to live trading. Auto-Trade is turned OFF automatically — re-enable it explicitly. You still need a Hyperliquid API key before the bot can trade.', danger: true, }) if (!ok) return setSubErr(''); setSubState('signing') try { // paper_mode=false → signedBody=null (same as original live subscribe) const env = await signRequest({ action: 'subscribe', wallet: address, body: null, signMessageAsync }) setSubState('saving') await subscribe(env, { paper_mode: false }) setPaperMode(false) void refreshUserState(address) setSubState('idle') } catch (e: unknown) { setSubErr(walletErrorLabel(e, 'Cancelled', 100)) setSubState('err') } } useEffect(() => { if (!manualUntil) return const id = setInterval(() => setMwTick(t => t + 1), 15000) return () => clearInterval(id) }, [manualUntil]) // ── Not connected ────────────────────────────────────────────────────────── if (!mounted || !isConnected) { return (
Connect your wallet to continue
Your subscription, API key, and trade settings are wallet-bound. No account needed — just sign a message.
{connectErr &&

{connectErr}

}
) } // ── helpers ──────────────────────────────────────────────────────────────── const Switch = ({ on, onChange, tone = 'amber' }: { on: boolean; onChange: (v: boolean) => void; tone?: 'amber' | 'up' }) => ( ) const trumpOn = !!settings.trump_enabled const macroOn = !!settings.macro_enabled // ── Not subscribed ───────────────────────────────────────────────────────── if (!isSubscribed) { return (
Set up your bot
Trade-only API key — no withdrawals. Try Paper first, or Live if you already have a key.
{/* Paper / Live choice */}
{subPaperChoice === 'paper' ? '📝 Paper mode — simulated trades, no real funds' : '💰 Live trading — real positions on Hyperliquid'}
{subPaperChoice === 'paper' ? 'Nothing is at risk. No Hyperliquid API key needed. You can switch to live at any time.' : 'Real positions will be opened and managed on Hyperliquid. A trade-only API key is required before the first trade.'}
Paper { setSubPaperChoice(v ? 'live' : 'paper') // Clear any stale subscribe-error from a previous attempt so // the user sees a clean state for the new paper/live choice. if (subState === 'err') { setSubState('idle'); setSubErr('') } }} tone="amber" /> Live
{subState === 'err' && {subErr}}
) } // ── Subscribed but settings not loaded yet ───────────────────────────────── if (loadState !== 'loaded') { return (
Sign in to view your settings
Settings are wallet-private. Sign once to load — cached for 4 min.
{loadState === 'err' && {loadErr}}
) } // ── Main config (subscribed + loaded) ────────────────────────────────────── // Readiness check — shown at bottom near save button const missingItems: string[] = [] if (!paperMode && !hlApiKeySet) missingItems.push('link a Hyperliquid API key') if (!trumpOn && !macroOn) missingItems.push('enable Trump Signal or Macro Vibes below') if (trumpOn && !tpConfigured) missingItems.push('set a Trump Signal take-profit %') if (trumpOn && !slConfigured) missingItems.push('set a Trump Signal stop-loss %') // Macro Vibes is manage-only through the Telegram /adopt command. If Macro is // the only enabled system and Telegram isn't bound, the bot can't manage any // position — so "ready" would be a lie. (tgBound === null = status unknown / // still loading: don't block on it.) if (macroOn && !trumpOn && tgBound === false) { missingItems.push('connect Telegram (Settings → Telegram) so the bot can manage Macro positions via /adopt') } const botReady = missingItems.length === 0 // Manual window countdown void mwTick const untilMs = manualUntil ? new Date(manualUntil).getTime() : 0 const remainingMin = untilMs ? Math.max(0, Math.round((untilMs - Date.now()) / 60000)) : 0 const armed = remainingMin > 0 const remainingLabel = remainingMin >= 60 ? `${Math.floor(remainingMin / 60)}h ${remainingMin % 60}m` : `${remainingMin}m` const mwBusy = mwState === 'signing' || mwState === 'saving' const saveBtnLabel = saveState === 'signing' ? 'Sign in wallet…' : saveState === 'saving' ? 'Saving…' : saveState === 'ok' ? '✓ Saved' : 'Save changes' return (
{/* ── Onboarding stepper ─────────────────────────────────────────────── */} {(!isSubscribed || !(hlApiKeySet || paperMode) || !autoTrade) && (
Setup progress
{/* Steps row: steps are fixed-width, connectors flex-grow to fill */}
{([ { label: 'Subscribe', done: isSubscribed }, { label: 'Add HL key', done: hlApiKeySet || paperMode }, { label: 'Enable Auto-Trade', done: autoTrade }, ] as const).map((step, i, arr) => { const isActive = !step.done && (i === 0 || arr[i - 1].done) const isLast = i === arr.length - 1 return (
{/* Circle + label — fixed, never stretches */}
{step.done ? '✓' : i + 1}
{step.label}
{/* Connector — takes all remaining space between this and next step */} {!isLast && (
)}
) })}
{/* Bottom progress bar */}
)} {/* ── HL API Key ─────────────────────────────────────────────────────── */}
{/* Status dot */}
{(paperMode || hlApiKeySet) ? '✓' : '◇'}
Hyperliquid API wallet
{paperMode ? 📝 Paper mode — no real money involved. Add an API key below to switch to live. : hlApiKeySet && !apiKey ? <>{hlApiKeyMasked ?? '···'} · trade-only · cannot withdraw : <>Don't have Hyperliquid?{' '} Sign up free ↗ {' '}· Then go to API → generate a trade-only key → paste below.}
{/* Paper mode: show upgrade path instead of key input */} {paperMode && ( )} {/* Live mode: show key input */} {!paperMode && (hlApiKeySet && !apiKey ? ( ) : (
{ setApiKey(e.target.value); if (keyState !== 'idle') setKeyState('idle') }} placeholder="0x…" style={{ width: 200, maxWidth: '100%', fontFamily: 'var(--mono)', fontSize: 12, padding: '8px 10px' }} />
))}
{keyState === 'err' &&
{keyErr}
} {keyState === 'ok' &&
Saved. Enable Auto-Trade below, then try a paper trade first to confirm the live path works.
} {!paperMode && !hlApiKeySet && (
⚠️
Do not paste your main wallet private key.{' '} This field only accepts a Hyperliquid trade-only API key — generate one at{' '} app.hyperliquid.xyz/API {' '}→ “Generate API wallet”. A trade-only key cannot withdraw funds.
)} {subState === 'err' && subErr &&
{subErr}
}
{/* ── Auto-Trade master switch ───────────────────────────────────────── */} {isSubscribed && (hlApiKeySet || paperMode) && (
Auto-Trade
{autoTrade ? 'ON — bot opens positions automatically when qualifying Trump signals fire.' : 'OFF — signals are shown but no trades are opened. Turn on when ready to go live.'}
{atState !== 'idle' && (
{atState === 'signing' ? 'Waiting for wallet signature…' : atState === 'saving' ? 'Saving…' : 'Failed to update — please try again.'}
)}
)} {/* ── Trump Signal ───────────────────────────────────────────────────── */}
{/* Header */}
Trump Signal
{trumpOn ? 'Opens positions when a Trump Truth Social post fires a signal.' : 'Disabled — bot ignores Trump posts.'}
updateSettings({ trump_enabled: v })} tone="amber" />
{/* Form — only when enabled */} {trumpOn && (
Per-trade size How much to bet per trade. At {settings.leverage}×, HL holds ~${(settings.position_size_usd / settings.leverage).toFixed(0)} as collateral.
$ updateSettings({ position_size_usd: Math.max(5, +e.target.value || 0) })} />
$5 – $10,000
Leverage How aggressive the position is. Start low (2–3×) and increase once you've seen the bot trade live.
updateSettings({ leverage: +e.target.value })} />
25×50×
{settings.leverage}× {settings.leverage > 10 && (
{settings.leverage}× is high for event-driven signals. A 1.5% stop-loss at {settings.leverage}× means the trade closes on a {(1.5 / settings.leverage).toFixed(1)}% price move against you. Consider 3–5× until you see how the bot performs live.
)}
Min AI confidence Filter out weak signals. Higher = fewer trades, but only the strongest ones.
updateSettings({ min_confidence: +e.target.value })} />
050100
{settings.min_confidence}%
Take profit * Bot locks in profit when the position gains this much. Required.
updateSettings({ take_profit_pct: e.target.value === '' ? null : +e.target.value })} /> % gain
{!tpConfigured && Required — not saved}
Stop loss * Bot cuts the loss when the position falls this much. Required.
updateSettings({ stop_loss_pct: e.target.value === '' ? null : +e.target.value })} /> % loss
{!slConfigured && Required — not saved}
)}
{/* ── Macro Vibes ────────────────────────────────────────────────────── */}
{/* Header */}
Macro Vibes
{macroOn ? 'When a signal fires: open a BTC long on Hyperliquid → type /adopt in the Telegram bot → bot manages the exit for you.' : 'Disabled — Macro Vibes alerts sent without bot management. Enable + set up Telegram to activate.'}
updateSettings({ macro_enabled: v })} tone="up" />
{/* Form — only when enabled */} {macroOn && (() => { const aggressive = settings.sys2_mode === 'aggressive' const defLev = aggressive ? 8 : 2 const lev = Math.max(1, Math.min(10, settings.sys2_leverage ?? defLev)) const prot = Math.min(35, 0.85 * 100 / lev) const liq = 100 / lev const risky = lev > 2 return (
BTC strategy mode Standard is calmer. Aggressive uses higher leverage and wider pyramiding.
{aggressive && (
High-risk sleeve. More leverage, harder pyramiding, wider peak-trail. Treat as a separate risk bucket.
)}
BTC bottom leverage Higher leverage = less room for BTC to drop before the bot starts reducing the position.
updateSettings({ sys2_leverage: +e.target.value })} />
10×
{lev}×
{risky ? `⚠️ At ${lev}×, BTC only needs to drop ${prot.toFixed(0)}% before the bot fully closes the position. A normal correction can trigger early exits — use lower leverage unless you're comfortable with that.` : `At ${lev}×, the bot has room for a ${prot.toFixed(0)}% BTC drop before closing. It reduces the position gradually as the drawdown deepens, so you don't lose everything at once.`}
) })()}
{/* ── Advanced / Global controls ─────────────────────────────────────── */}
{showAdvanced && (
{/* Daily spend cap */}
Daily trading cap Daily spending cap — bot stops opening new trades once it hits this amount.
{ setUseBudget(v); setDirty(true) }} /> {useBudget ? (
$ updateSettings({ daily_budget_usd: e.target.value === '' ? null : Math.max(0, +e.target.value) })} /> /day
) : ( No cap )}
{/* Schedule */}
Trading schedule Daily recurring UTC window — bot only opens new trades within this time range. Overnight windows (e.g. 22:00–02:00) are allowed. Leave off for 24/7.
{ setUseSchedule(v); setDirty(true) }} /> {useSchedule && (
From { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 110 }} />
Until { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 110 }} />
)}
{/* Override window — only relevant when a schedule is set */} {useSchedule && (
Override window Temporarily bypass your schedule. Useful when a high-conviction event (e.g. CPI, FOMC) happens outside your trading hours.
{armed ? ( <> ● Override active — {remainingLabel} remaining ) : ( [4, 12, 24].map(h => ( )) )} {mwState === 'err' && {mwErr}}
)}
)}
{/* ── Save ───────────────────────────────────────────────────────────── */}
{/* Status */}
{saveState === 'err' ? ( ⚠ {saveErr} ) : botReady ? ( ✓ Bot is configured and ready ) : ( Still needed: {missingItems.join(' · ')} )}
{/* Button */}
) }