Files
trumpsignal-frontend/components/trades/BotConfigPanel.tsx
T

1111 lines
54 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
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 <input type="time">. 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<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 [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<boolean | null>(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<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: '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:0002: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 cant 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.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: '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 (
<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 bot</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.55, marginBottom: 24 }}>
Trade-only API key no withdrawals. Try Paper first, or Live if you already have a 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.55, marginBottom: 20 }}>
Settings are wallet-private. Sign once to load cached for 4 min.
</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 %')
// 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 (
<div style={{ marginBottom: 28 }} id="bot-config-panel">
{/* ── Onboarding stepper ─────────────────────────────────────────────── */}
{(!isSubscribed || !(hlApiKeySet || paperMode) || !autoTrade) && (
<div className="card" style={{ padding: '14px 18px 16px', marginBottom: 12 }}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 14 }}>
Setup progress
</div>
{/* Steps row: steps are fixed-width, connectors flex-grow to fill */}
<div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: 14 }}>
{([
{ 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 (
<div key={step.label} style={{
display: 'flex', alignItems: 'flex-start',
flex: isLast ? '0 0 auto' : 1, // last step: natural width; others: expand via connector
minWidth: 0,
}}>
{/* Circle + label — fixed, never stretches */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<div style={{
width: 24, height: 24, borderRadius: '50%',
fontSize: 11, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: step.done ? 'var(--up)' : isActive ? 'var(--amber, #f59e0b)' : 'var(--bg-sunk)',
color: step.done ? '#fff' : isActive ? '#000' : 'var(--ink-4)',
border: `2px solid ${step.done ? 'var(--up)' : isActive ? 'var(--amber, #f59e0b)' : 'var(--line)'}`,
boxSizing: 'border-box',
}}>
{step.done ? '✓' : i + 1}
</div>
<div style={{
fontSize: 10, lineHeight: 1.3,
textAlign: isLast ? 'right' : 'center',
fontWeight: isActive ? 600 : 400,
color: step.done ? 'var(--up)' : isActive ? 'var(--ink)' : 'var(--ink-4)',
whiteSpace: 'nowrap',
}}>
{step.label}
</div>
</div>
{/* Connector — takes all remaining space between this and next step */}
{!isLast && (
<div style={{
flex: 1, height: 2, marginTop: 11,
background: step.done ? 'var(--up)' : 'var(--line)',
transition: 'background 0.3s',
}} />
)}
</div>
)
})}
</div>
{/* Bottom progress bar */}
<div style={{ height: 3, borderRadius: 999, background: 'var(--bg-sunk)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 999,
background: 'var(--up)',
width: `${Math.round(([isSubscribed, hlApiKeySet || paperMode, autoTrade].filter(Boolean).length / 3) * 100)}%`,
transition: 'width .4s ease',
}} />
</div>
</div>
)}
{/* ── 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></>
: <>Don&apos;t have Hyperliquid?{' '}
<a href="https://app.hyperliquid.xyz" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--amber)', textDecoration: 'none' }}>Sign up free </a>
{' '}· Then go to API generate a trade-only key paste below.</>}
</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. Enable Auto-Trade below, then try a paper trade first to confirm the live path works.</div>}
{!paperMode && !hlApiKeySet && (
<div style={{
marginTop: 10, paddingLeft: 50,
display: 'flex', alignItems: 'flex-start', gap: 6,
}}>
<span style={{ fontSize: 13, flexShrink: 0, marginTop: 1 }}></span>
<div style={{ fontSize: 11, color: 'var(--down)', lineHeight: 1.5 }}>
<strong>Do not paste your main wallet private key.</strong>{' '}
This field only accepts a Hyperliquid <em>trade-only API key</em> generate one at{' '}
<a href="https://app.hyperliquid.xyz/API" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--down)', textDecorationColor: 'var(--down)' }}>
app.hyperliquid.xyz/API
</a>
{' '} &ldquo;Generate API wallet&rdquo;. A trade-only key cannot withdraw funds.
</div>
</div>
)}
{subState === 'err' && subErr && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8 }}>{subErr}</div>}
</div>
{/* ── Auto-Trade master switch ───────────────────────────────────────── */}
{isSubscribed && (hlApiKeySet || paperMode) && (
<div className="card" style={{ padding: '14px 20px', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>Auto-Trade</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4 }}>
{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.'}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button
onClick={() => flipAutoTrade(true)}
disabled={atState !== 'idle'}
style={{
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
border: 'none',
background: autoTrade ? 'var(--up)' : 'var(--bg-sunk)',
color: autoTrade ? '#fff' : 'var(--ink-4)',
opacity: atState !== 'idle' ? 0.5 : 1,
}}
>ON</button>
<button
onClick={() => flipAutoTrade(false)}
disabled={atState !== 'idle'}
style={{
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
border: '1px solid var(--line)',
background: !autoTrade ? 'var(--ink)' : 'transparent',
color: !autoTrade ? 'var(--bg)' : 'var(--ink-4)',
opacity: atState !== 'idle' ? 0.5 : 1,
}}
>OFF</button>
</div>
</div>
{atState !== 'idle' && (
<div style={{ fontSize: 11, marginTop: 8, color: atState === 'err' ? 'var(--down)' : 'var(--ink-4)' }}>
{atState === 'signing' ? 'Waiting for wallet signature…'
: atState === 'saving' ? 'Saving…'
: 'Failed to update — please try again.'}
</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">How much to bet per trade. At {settings.leverage}×, HL holds ~${(settings.position_size_usd / settings.leverage).toFixed(0)} as collateral.</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">How aggressive the position is. Start low (23×) and increase once you've seen the bot trade live.</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>
{settings.leverage > 10 && (
<div className="settings-note warn" style={{ marginTop: 6 }}>
{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 35× until you see how the bot performs live.
</div>
)}
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Min AI confidence
<span className="hint">Filter out weak signals. Higher = fewer trades, but only the strongest ones.</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">Bot locks in profit when the position gains this much.</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">Bot cuts the loss when the position falls this much.</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
? '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.'}
</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">Higher leverage = less room for BTC to drop before the bot starts reducing the position.</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 }}>
{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.`}
</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)' }}>Risk limits &amp; schedule</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">Bot stops opening new trades once it has spent this amount in a 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">Daily recurring UTC window — bot only opens new trades within this time range. Overnight windows (e.g. 22:0002:00) are allowed. Leave off for 24/7.</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="time" value={fromLocal}
onChange={e => { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 110 }} />
</div>
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>→</span>
<div className="num-field">
<span className="prefix">Until</span>
<input type="time" value={untilLocal}
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 110 }} />
</div>
</div>
)}
</div>
</div>
{/* Override window — only relevant when a schedule is set */}
{useSchedule && (
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Override window
<span className="hint">Temporarily bypass your schedule. Useful when a high-conviction event (e.g. CPI, FOMC) happens outside your trading hours.</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'}
</button>
</>
) : (
[4, 12, 24].map(h => (
<button key={h} className="btn ghost" style={{ padding: '6px 12px', fontSize: 12 }}
onClick={() => handleManualWindow(h)} disabled={mwBusy}>
{mwBusy && mwState === 'signing' ? 'Sign' : `+${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>
)
}