Files
trumpsignal-frontend/components/trades/BotConfigPanel.tsx
T
k 594d9817ba fix(analytics): never mix paper + live P&L; settings live-switch copy
Money-safety/accuracy bug: analytics computed total P&L, win rate, drawdown,
and every metric over ALL trades regardless of is_paper. A user who tried
paper mode then went live saw simulated and real P&L summed into one number
on the page whose entire purpose is 'did the bot make REAL money'.

Now: split trades by is_paper. Default to LIVE. A Live/Paper toggle appears
only when the wallet has both; otherwise auto-pick whichever exists. Paper
view shows a prominent 'SIMULATED — not real-money results' banner. All
metrics derive from the selected mode's trades only.

Also: BotConfigPanel handleUpgradeToLive copy now states Auto-Trade is forced
OFF on the paper→live switch (matches backend subscribe.py safety reset).

tsc + next build clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 01:03:15 +08:00

881 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useConnect, useSignMessage } from 'wagmi'
import {
getUserPublic,
getUser,
setUserSettings,
setHlApiKey,
setManualWindow,
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,
}
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 `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function localInputToIso(v: string): string | null {
if (!v) return null
const d = new Date(v)
if (isNaN(d.getTime())) return null
return d.toISOString()
}
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,
} = useDashboardStore()
const [settings, setSettings] = useState<UserSettings>(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<string | null>(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 [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('')
useEffect(() => { setMounted(true) }, [])
function applyUserPayload(u: {
active: boolean
hl_api_key_set: boolean
hl_api_key_masked: string | null
paper_mode?: boolean
settings: UserSettings
manual_window_until?: string | null
}) {
setSubscribed(u.active)
setPaperMode(!!u.paper_mode)
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')
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 {
const ok = await confirmSign({
label: 'View account settings',
description: 'Read your Trump Alpha subscription state, risk settings, and API key status. The signature is only for identity verification — no on-chain action is performed.',
})
if (!ok) { setLoadState('idle'); return }
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')
return pub
}
function updateSettings(patch: Partial<UserSettings>) {
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: 'Settings are saved to the server and apply to all new trades from this point 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 }
if (new Date(schedUntil).getTime() <= new Date(schedFrom).getTime()) { setSaveErr('End must be after start'); 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.150%'); setSaveState('err'); return }
if (sl == null || sl < 0.1 || sl > 50) { setSaveErr('Stop loss must be 0.150%'); 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: 'The API key is stored encrypted on the server and used by the bot to place trades on Hyperliquid for you.',
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: 'Your subscription will change from paper mode to live. For your safety, Auto-Trade is turned OFF on this switch — you must re-enable it explicitly while live. No trade opens immediately; you still need to add a Hyperliquid API key first.',
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 (
<div className="card" style={{ padding: '32px 28px', textAlign: 'center', marginBottom: 28 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>Connect your wallet to continue</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', marginBottom: 20, lineHeight: 1.6 }}>
Your subscription, API key, and trade settings are wallet-bound. No account needed just sign a message.
</div>
<button className="btn amber" style={{ padding: '10px 24px' }} onClick={() => { void handleConnectWallet() }}>
Connect wallet
</button>
{connectErr && <p style={{ fontSize: 12, color: 'var(--down)', margin: '12px 0 0' }}>{connectErr}</p>}
</div>
)
}
// ── helpers ────────────────────────────────────────────────────────────────
const Switch = ({ on, onChange, tone = 'amber' }: { on: boolean; onChange: (v: boolean) => void; tone?: 'amber' | 'up' }) => (
<label className={`switch ${tone === 'amber' ? '' : tone}`}>
<input type="checkbox" checked={on} onChange={e => { onChange(e.target.checked); setDirty(true) }} />
<span className="switch-track" />
</label>
)
const trumpOn = !!settings.trump_enabled
const macroOn = !!settings.macro_enabled
// ── Not subscribed ─────────────────────────────────────────────────────────
if (!isSubscribed) {
return (
<div className="card" id="bot-config-panel" style={{ padding: '28px 28px 24px', marginBottom: 28 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Set up your trading bot</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 24 }}>
The bot places trades on Hyperliquid with a trade-only API key it can never withdraw funds.
Start in paper mode to try it safely, or choose Live if you already have a Hyperliquid API key.
</div>
{/* Paper / Live choice */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 16px', borderRadius: 10, marginBottom: 16,
background: subPaperChoice === 'paper' ? 'var(--up-soft)' : 'var(--amber-soft)',
border: `1px solid ${subPaperChoice === 'paper'
? 'color-mix(in oklab, var(--up) 25%, var(--line))'
: 'color-mix(in oklab, var(--amber-ink) 25%, var(--line))'}`,
}}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{subPaperChoice === 'paper' ? '📝 Paper mode — simulated trades, no real funds' : '💰 Live trading — real positions on Hyperliquid'}
</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 3, lineHeight: 1.5 }}>
{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.'}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<span style={{ fontSize: 11, color: subPaperChoice === 'paper' ? 'var(--ink-2)' : 'var(--ink-4)', fontWeight: subPaperChoice === 'paper' ? 600 : 400 }}>Paper</span>
<Switch
on={subPaperChoice === 'live'}
onChange={v => {
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"
/>
<span style={{ fontSize: 11, color: subPaperChoice === 'live' ? 'var(--amber-ink)' : 'var(--ink-4)', fontWeight: subPaperChoice === 'live' ? 600 : 400 }}>Live</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
className="btn amber"
style={{ padding: '10px 22px', fontSize: 13 }}
onClick={handleSubscribe}
disabled={subState === 'signing' || subState === 'saving'}
>
{subState === 'signing' ? 'Sign in wallet…' : subState === 'saving' ? 'Activating…' : 'Get started →'}
</button>
{subState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{subErr}</span>}
</div>
</div>
)
}
// ── Subscribed but settings not loaded yet ─────────────────────────────────
if (loadState !== 'loaded') {
return (
<div className="card" id="bot-config-panel" style={{ padding: '28px 28px 24px', marginBottom: 28 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 4 }}>Sign in to view your settings</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 20 }}>
Your settings are private and wallet-bound. Sign once to load them the permission is cached for 4 minutes so you won&apos;t be prompted again while you browse.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button className="btn amber" style={{ padding: '10px 22px', fontSize: 13 }} onClick={handleLoadSettings} disabled={loadState === 'loading'}>
{loadState === 'loading' ? 'Waiting for signature…' : 'Load my settings'}
</button>
{loadState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{loadErr}</span>}
</div>
</div>
)
}
// ── 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 %')
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 (
<div style={{ marginBottom: 28 }} id="bot-config-panel">
{/* ── HL API Key ─────────────────────────────────────────────────────── */}
<div className="card" style={{ padding: '16px 20px', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{/* Status dot */}
<div style={{
width: 36, height: 36, borderRadius: 9, flexShrink: 0,
background: (paperMode || hlApiKeySet) ? 'var(--up-soft)' : 'var(--bg-sunk)',
border: `1px solid ${(paperMode || hlApiKeySet) ? 'color-mix(in oklab, var(--up) 25%, var(--line))' : 'var(--line)'}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: (paperMode || hlApiKeySet) ? 'var(--up)' : 'var(--ink-4)',
}}>
{(paperMode || hlApiKeySet) ? '✓' : '◇'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Hyperliquid API wallet</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 1 }}>
{paperMode
? <span style={{ color: 'var(--up)' }}>📝 Paper mode no real money involved. Add an API key below to switch to live.</span>
: hlApiKeySet && !apiKey
? <><span className="mono">{hlApiKeyMasked ?? '···'}</span><span> · trade-only · cannot withdraw</span></>
: 'Generate at app.hyperliquid.xyz/API — paste the private key here. Never your MetaMask key.'}
</div>
</div>
{/* Paper mode: show upgrade path instead of key input */}
{paperMode && (
<button
className="btn amber"
style={{ padding: '6px 14px', fontSize: 12, flexShrink: 0 }}
onClick={handleUpgradeToLive}
disabled={subState === 'signing' || subState === 'saving'}
>
{subState === 'signing' ? 'Sign…' : subState === 'saving' ? 'Switching…' : 'Switch to live →'}
</button>
)}
{/* Live mode: show key input */}
{!paperMode && (hlApiKeySet && !apiKey ? (
<button
className="btn ghost"
style={{ padding: '6px 14px', fontSize: 12, flexShrink: 0 }}
onClick={() => {
// Switch to edit mode AND clear any stale error / status from
// a previous failed save so the row isn't pre-stained red.
setApiKey(' '); setKeyState('idle'); setKeyErr('')
}}
>Rotate</button>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<input
value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
onChange={e => { setApiKey(e.target.value); if (keyState !== 'idle') setKeyState('idle') }}
placeholder="0x…"
style={{ width: 200, maxWidth: '100%', fontFamily: 'var(--mono)', fontSize: 12, padding: '8px 10px' }}
/>
<button
className={`btn ${keyState === 'ok' ? 'ghost' : 'amber'}`}
style={{ padding: '8px 14px', fontSize: 12, flexShrink: 0 }}
onClick={handleSaveKey}
disabled={keyState === 'signing' || keyState === 'saving' || !apiKey.trim()}
>
{keyState === 'signing' ? 'Sign…' : keyState === 'saving' ? 'Saving…' : keyState === 'ok' ? '✓' : 'Save'}
</button>
</div>
))}
</div>
{keyState === 'err' && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8, paddingLeft: 50 }}>{keyErr}</div>}
{keyState === 'ok' && <div style={{ fontSize: 11, color: 'var(--up)', marginTop: 6, paddingLeft: 50 }}>Saved. Run one small test trade to confirm the live path end-to-end.</div>}
{subState === 'err' && subErr && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8 }}>{subErr}</div>}
</div>
{/* ── Trump Signal ───────────────────────────────────────────────────── */}
<div id="config-trump" className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 12 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: trumpOn ? '1px solid var(--line)' : 'none',
background: trumpOn ? 'var(--amber-soft)' : undefined,
transition: 'background 0.2s',
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: trumpOn ? 'var(--amber-ink)' : 'var(--ink)' }}>
Trump Signal
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>
{trumpOn
? 'Opens positions when a Trump Truth Social post fires a signal.'
: 'Disabled — bot ignores Trump posts.'}
</div>
</div>
<Switch on={trumpOn} onChange={v => updateSettings({ trump_enabled: v })} tone="amber" />
</div>
{/* Form — only when enabled */}
{trumpOn && (
<div style={{ padding: '20px 20px 8px' }}>
<div className="form-row">
<div className="form-row-label">
Per-trade size
<span className="hint">Notional in USD margin ${(settings.position_size_usd / settings.leverage).toFixed(2)} at {settings.leverage}×</span>
</div>
<div className="form-row-control">
<div className="num-field">
<span className="prefix">$</span>
<input type="number" min={5} max={10000} step={5} value={settings.position_size_usd}
onChange={e => updateSettings({ position_size_usd: Math.max(5, +e.target.value || 0) })} />
</div>
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>$5 $10,000</span>
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Leverage
<span className="hint">Event-driven scalp use lower leverage if unsure</span>
</div>
<div className="form-row-control">
<div className="slider-field">
<input type="range" min={1} max={50} value={settings.leverage}
onChange={e => updateSettings({ leverage: +e.target.value })} />
<div className="ticks"><span>1×</span><span>25×</span><span>50×</span></div>
</div>
<span className="slider-readout">{settings.leverage}×</span>
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Min AI confidence
<span className="hint">Skip signals below this score (0 = take all, 100 = only the highest-conviction)</span>
</div>
<div className="form-row-control">
<div className="slider-field">
<input type="range" min={0} max={100} value={settings.min_confidence}
onChange={e => updateSettings({ min_confidence: +e.target.value })} />
<div className="ticks"><span>0</span><span>50</span><span>100</span></div>
</div>
<span className="slider-readout">{settings.min_confidence}%</span>
</div>
</div>
<div style={{ height: 1, background: 'var(--line)', margin: '8px 0 16px' }} />
<div className="form-row">
<div className="form-row-label">
Take profit <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when unrealised gain hits this target. Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
<input type="number" min={0.1} max={50} step={0.1}
value={settings.take_profit_pct ?? ''}
onChange={e => updateSettings({ take_profit_pct: e.target.value === '' ? null : +e.target.value })} />
<span className="suffix">% gain</span>
</div>
{!tpConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Required not saved</span>}
</div>
</div>
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when drawdown hits this limit. Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
<input type="number" min={0.1} max={50} step={0.1}
value={settings.stop_loss_pct ?? ''}
onChange={e => updateSettings({ stop_loss_pct: e.target.value === '' ? null : +e.target.value })} />
<span className="suffix">% loss</span>
</div>
{!slConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Required not saved</span>}
</div>
</div>
</div>
)}
</div>
{/* ── Macro Vibes ────────────────────────────────────────────────────── */}
<div id="config-macro" className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 12 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: macroOn ? '1px solid var(--line)' : 'none',
background: macroOn ? 'var(--up-soft)' : undefined,
transition: 'background 0.2s',
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: macroOn ? 'var(--up)' : 'var(--ink)' }}>
Macro Vibes
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>
{macroOn
? 'You open a BTC long on Hyperliquid, then use /adopt in the Telegram bot to hand it to the bot for exit management.'
: 'Disabled — Macro Vibes alerts will not include bot management instructions.'}
</div>
</div>
<Switch on={macroOn} onChange={v => updateSettings({ macro_enabled: v })} tone="up" />
</div>
{/* 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 (
<div style={{ padding: '20px 20px 8px' }}>
<div className="form-row">
<div className="form-row-label">
BTC strategy mode
<span className="hint">Standard is calmer. Aggressive uses higher leverage and wider pyramiding.</span>
</div>
<div className="form-row-control">
<div style={{
display: 'inline-flex', gap: 6, padding: 4,
borderRadius: 999, background: 'var(--bg-sunk)', border: '1px solid var(--line)',
}}>
<button type="button"
onClick={() => updateSettings({ sys2_mode: 'standard' })}
aria-pressed={!aggressive}
style={{
padding: '8px 16px', fontSize: 12, fontWeight: 700, borderRadius: 999, cursor: 'pointer',
border: !aggressive ? '1px solid var(--line)' : '1px solid transparent',
background: !aggressive ? 'var(--surface)' : 'transparent',
color: !aggressive ? 'var(--ink)' : 'var(--ink-3)',
boxShadow: !aggressive ? 'var(--shadow-1)' : 'none',
}}>
Standard
</button>
<button type="button"
onClick={() => updateSettings({ sys2_mode: 'aggressive' })}
aria-pressed={aggressive}
style={{
padding: '8px 16px', fontSize: 12, fontWeight: 700, borderRadius: 999, cursor: 'pointer',
border: aggressive ? '1px solid var(--down)' : '1px solid transparent',
background: aggressive ? 'var(--down)' : 'transparent',
color: aggressive ? '#fff' : 'var(--ink-3)',
boxShadow: aggressive ? 'var(--shadow-1)' : 'none',
}}>
🔥 Aggressive
</button>
</div>
{aggressive && (
<div className="settings-note warn" style={{ marginTop: 6 }}>
High-risk sleeve. More leverage, harder pyramiding, wider peak-trail. Treat as a separate risk bucket.
</div>
)}
</div>
</div>
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
BTC bottom leverage
<span className="hint">Separate from Trump leverage. The bot de-risks in stages before exchange liquidation.</span>
</div>
<div className="form-row-control">
<div className="slider-field">
<input type="range" min={1} max={10} value={lev}
onChange={e => updateSettings({ sys2_leverage: +e.target.value })} />
<div className="ticks"><span>1×</span><span>5×</span><span>10×</span></div>
</div>
<span className="slider-readout">{lev}×</span>
<div className={`settings-note ${risky ? 'warn' : ''}`} style={{ marginTop: 6 }}>
At {lev}× it sheds near {(prot * 0.6).toFixed(0)}%, near {(prot * 0.8).toFixed(0)}%, fully out by {prot.toFixed(0)}%.
Exchange liquidation {liq.toFixed(0)}%.
{risky ? ' Above 2×, a normal correction can push you out early.' : ' Wide enough to survive a normal correction.'}
</div>
</div>
</div>
</div>
)
})()}
</div>
{/* ── Advanced / Global controls ─────────────────────────────────────── */}
<div id="config-global" className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 12 }}>
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
style={{
width: '100%', padding: '14px 20px', display: 'flex', alignItems: 'center',
justifyContent: 'space-between', background: 'transparent', border: 'none',
cursor: 'pointer', borderBottom: showAdvanced ? '1px solid var(--line)' : 'none',
}}
>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-2)' }}>Advanced</span>
<span style={{
fontSize: 11, color: 'var(--ink-4)',
display: 'flex', alignItems: 'center', gap: 10,
}}>
{armed && <span style={{ color: 'var(--up)', fontWeight: 600 }}> Override {remainingLabel}</span>}
{useBudget && settings.daily_budget_usd && <span>${settings.daily_budget_usd}/day limit</span>}
{useSchedule && <span>Scheduled</span>}
<span style={{ fontSize: 13 }}>{showAdvanced ? '▴' : '▾'}</span>
</span>
</button>
{showAdvanced && (
<div style={{ padding: '16px 20px 8px' }}>
{/* Daily spend cap */}
<div className="form-row">
<div className="form-row-label">
Daily trading cap
<span className="hint">Optional bot stops opening new trades once total notional crosses this limit in a UTC day.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 10 }}>
<Switch on={useBudget} onChange={v => { setUseBudget(v); setDirty(true) }} />
{useBudget ? (
<div className="num-field">
<span className="prefix">$</span>
<input type="number" min={1} max={100000} step={5}
value={settings.daily_budget_usd ?? ''}
onChange={e => updateSettings({ daily_budget_usd: e.target.value === '' ? null : Math.max(0, +e.target.value) })} />
<span className="suffix">/day</span>
</div>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>No cap</span>
)}
</div>
</div>
{/* Schedule */}
<div className="form-row">
<div className="form-row-label">
Trading schedule
<span className="hint">Bot only accepts signals inside this window. Times in your browser&apos;s local timezone. Leave off to trade anytime.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap' }}>
<Switch on={useSchedule} onChange={v => { setUseSchedule(v); setDirty(true) }} />
{useSchedule && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div className="num-field">
<span className="prefix">From</span>
<input type="datetime-local" lang="en-US" value={fromLocal}
onChange={e => { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
</div>
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>
<div className="num-field">
<span className="prefix">Until</span>
<input type="datetime-local" lang="en-US" value={untilLocal}
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
</div>
</div>
)}
</div>
</div>
{/* Manual window */}
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Override window
<span className="hint">Open a timed override so the bot accepts signals for the next 124 hours useful for high-conviction catalysts like CPI or FOMC releases.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 8 }}>
{armed ? (
<>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--up)' }}> Override active {remainingLabel} remaining</span>
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }}
onClick={() => handleManualWindow(0)} disabled={mwBusy}>
{mwBusy ? 'Sign…' : 'Cancel override'}
</button>
</>
) : (
[1, 4, 24].map(h => (
<button key={h} className="btn ghost" style={{ padding: '6px 12px', fontSize: 12 }}
onClick={() => handleManualWindow(h)} disabled={mwBusy}>
{mwBusy && mwState === 'signing' ? 'Sign…' : `Override ${h}h`}
</button>
))
)}
{mwState === 'err' && <span style={{ fontSize: 11, color: 'var(--down)', width: '100%' }}>{mwErr}</span>}
</div>
</div>
</div>
)}
</div>
{/* ── Save ───────────────────────────────────────────────────────────── */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 12, flexWrap: 'wrap',
padding: '16px 20px',
background: 'var(--surface)',
border: '1px solid var(--line)',
borderRadius: 12,
}}>
{/* Status */}
<div style={{ fontSize: 12, color: botReady ? 'var(--up)' : 'var(--ink-3)', lineHeight: 1.5 }}>
{saveState === 'err' ? (
<span style={{ color: 'var(--down)' }}> {saveErr}</span>
) : botReady ? (
<span> Bot is configured and ready</span>
) : (
<span>Still needed: {missingItems.join(' · ')}</span>
)}
</div>
{/* Button */}
<button
className={`btn ${saveState === 'ok' ? 'ghost' : 'amber'}`}
style={{ padding: '10px 24px', fontSize: 13 }}
onClick={handleSaveSettings}
disabled={!dirty || saveState === 'signing' || saveState === 'saving'}
>
{saveBtnLabel}
</button>
</div>
</div>
)
}