1111 lines
54 KiB
TypeScript
1111 lines
54 KiB
TypeScript
'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: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 (
|
||
<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'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>
|
||
{' '}→ “Generate API wallet”. 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 (2–3×) 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 3–5× 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 & 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:00–02: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>
|
||
)
|
||
}
|