1656 lines
62 KiB
TypeScript
1656 lines
62 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>
|
||
)
|
||
}
|