'use client' import { useState, useEffect } from 'react' import { useAccount, useConnect, useSignMessage } from 'wagmi' import type { BotTrade, TrumpPost } from '@/types' import { getTrades, getPosts, getUserPublic, getUser, setUserSettings, setHlApiKey, subscribe, type UserSettings } from '@/lib/api' import { useDashboardStore } from '@/store/dashboard' import { signRequest, getOrCreateViewEnvelope, getCachedViewEnvelope } from '@/lib/signedRequest' // ─── helpers ────────────────────────────────────────────────────────────────── function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) { const { decimals = 2, sign = false } = opts if (n == null || isNaN(n)) return '—' const abs = Math.abs(n) const s = abs.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) if (n < 0) return '-$' + s if (sign && n > 0) return '+$' + s return '$' + s } function fmtPct(n: number) { return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' } function fmtHold(s: number) { if (s < 60) return s + 's' const m = Math.floor(s / 60) if (m < 60) return m + 'm' return Math.floor(m / 60) + 'h ' + (m % 60) + 'm' } // Defaults shown to a brand-new, un-configured wallet. take_profit_pct, // stop_loss_pct and daily_budget_usd are all REQUIRED before the bot will // trade — we still initialise them so the inputs have sensible placeholders. 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, active_from: null, active_until: null, } // ── datetime-local helpers (browser local tz ↔ ISO UTC) ── function isoToLocalInput(iso: string | null): string { if (!iso) return '' const d = new Date(iso) if (isNaN(d.getTime())) return '' const pad = (n: number) => String(n).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` } function localInputToIso(v: string): string | null { if (!v) return null const d = new Date(v) // interpreted as local time if (isNaN(d.getTime())) return null return d.toISOString() } // ─── Bot config panel ───────────────────────────────────────────────────────── function BotConfigPanel() { const { address, isConnected } = useAccount() const { connect, connectors } = useConnect() const { signMessageAsync } = useSignMessage() const { isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness, setSubscribed, setBotReadiness, setHlApiKeySet } = useDashboardStore() const [settings, setSettings] = useState(DEFAULT_SETTINGS) // Tracks whether the SERVER already has values for these required fields. // Used to flag "setup incomplete" until the user saves for the first time. const [tpConfigured, setTpConfigured] = useState(false) const [slConfigured, setSlConfigured] = useState(false) const [budgetConfigured, setBudgetConfigured] = 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('') // HL key state const [apiKey, setApiKey] = useState('') const [keyState, setKeyState] = useState<'idle'|'signing'|'saving'|'ok'|'err'>('idle') const [keyErr, setKeyErr] = useState('') // Subscribe state const [subState, setSubState] = useState<'idle'|'signing'|'saving'|'err'>('idle') const [subErr, setSubErr] = useState('') // Mounted flag: avoid SSR/CSR mismatch since wagmi state differs between the two. const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) // Settings-load state ("idle" | "loading" | "loaded" | "err"). We do NOT // auto-pop MetaMask — only fetch with a cached signature; otherwise wait // for the user to click "Load settings". const [loadState, setLoadState] = useState<'idle'|'loading'|'loaded'|'err'>('idle') const [loadErr, setLoadErr] = useState('') // Apply a fetched user payload into local form state. function applyUserPayload(u: { active: boolean; hl_api_key_set: boolean; hl_api_key_masked: string | null; settings: UserSettings }) { setSubscribed(u.active) setHlApiKeySet(u.hl_api_key_set, u.hl_api_key_masked ?? undefined) if (u.settings) { // Fall back to defaults when server returns null so the inputs are // still usable, but remember which required fields weren't configured // so we can show the "Setup incomplete" warning. const s = u.settings setTpConfigured(s.take_profit_pct != null) setSlConfigured(s.stop_loss_pct != null) setBudgetConfigured(s.daily_budget_usd != null) 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, }) const hasSched = s.active_from != null && s.active_until != null setUseSchedule(hasSched) setFromLocal(isoToLocalInput(s.active_from)) setUntilLocal(isoToLocalInput(s.active_until)) } } // On connect: fetch public state only (no signature). If a fresh view // envelope is already cached in sessionStorage, silently hydrate full data. useEffect(() => { if (!mounted || !isConnected || !address) return let cancelled = false ;(async () => { const pub = await getUserPublic(address.toLowerCase()).catch(() => null) if (!pub || cancelled) return setSubscribed(pub.active) setHlApiKeySet(pub.hl_api_key_set) setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown') if (!pub.active) return // Silent rehydrate: only if an unexpired cached envelope exists. 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]) // Explicit "Load settings" — the only place that may pop MetaMask. async function handleLoadSettings() { if (!address) return setLoadErr(''); setLoadState('loading') try { const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync }) const u = await getUser(address, env) applyUserPayload(u) setLoadState('loaded') } catch (e: unknown) { const m = e instanceof Error ? e.message : '' setLoadErr(m.includes('rejected') || m.includes('denied') ? 'Cancelled' : m.slice(0, 120)) setLoadState('err') } } async function refreshUserState(wallet: string) { const pub = await getUserPublic(wallet.toLowerCase()) setSubscribed(pub.active) setHlApiKeySet(pub.hl_api_key_set) setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown') return pub } function updateSettings(patch: Partial) { setSettings(s => ({ ...s, ...patch })) setDirty(true) } async function handleSubscribe() { if (!address) return setSubErr(''); setSubState('signing') try { const env = await signRequest({ action: 'subscribe', wallet: address, body: null, signMessageAsync }) setSubState('saving') await subscribe(env) setSubscribed(true) void refreshUserState(address) setSubState('idle') // Fresh subscribers have no settings/trades/api-key yet — skip the // view_user fetch entirely so they don't have to sign a SECOND popup // immediately after subscribing. The defaults in local state are fine; // we mark loadState='loaded' so the UI shows the configuration form. setTpConfigured(false) setSlConfigured(false) setBudgetConfigured(false) setLoadState('loaded') } catch (e: unknown) { const m = e instanceof Error ? e.message : '' setSubErr(m.includes('rejected') || m.includes('denied') ? 'Cancelled' : m.slice(0, 100)) setSubState('err') } } async function handleSaveSettings() { if (!address) return setSaveErr(''); setSaveState('signing') try { let schedFrom: string | null = null let schedUntil: string | null = null if (useSchedule) { schedFrom = localInputToIso(fromLocal) schedUntil = localInputToIso(untilLocal) if (!schedFrom || !schedUntil) { setSaveErr('Pick both a start and end time'); setSaveState('err'); return } if (new Date(schedUntil).getTime() <= new Date(schedFrom).getTime()) { setSaveErr('End must be after start'); setSaveState('err'); return } } // TP, SL and daily budget are mandatory; enforce here before signing. const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd 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 (bd == null || bd <= 0 || bd > 100000) { setSaveErr('Daily budget must be > $0'); setSaveState('err'); return } const payload: UserSettings = { ...settings, take_profit_pct: tp, stop_loss_pct: sl, daily_budget_usd: bd, 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(true); setSlConfigured(true); setBudgetConfigured(true) setBotReadiness('saved') setDirty(false) setSaveState('ok') setTimeout(() => setSaveState('idle'), 2000) } catch (e: unknown) { const m = e instanceof Error ? e.message : '' setSaveErr(m.includes('rejected') ? 'Cancelled' : m.slice(0, 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 } 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) { const m = e instanceof Error ? e.message : '' setKeyErr(m.includes('rejected') ? 'Cancelled' : m.slice(0, 100)) setKeyState('err') } } function handleConnectWallet() { const connector = connectors[0] if (connector) connect({ connector }) } // Before client mount, render a stable placeholder so SSR output matches // the first client paint (avoids hydration mismatch — wagmi state differs). if (!mounted) { return
} if (!isConnected) { return (

Connect your wallet to configure the bot and view your trades.

) } // ── small local helpers ── const Switch = ({ on, onChange, tone = 'amber' }: { on: boolean; onChange: (v: boolean) => void; tone?: 'amber' | 'up' | 'down' }) => ( ) const saveBtnLabel = saveState === 'signing' ? 'Sign in wallet…' : saveState === 'saving' ? 'Saving…' : saveState === 'ok' ? '✓ Saved' : 'Save changes' const keyHint = hlApiKeySet && !apiKey ? 'Saved in your bot profile. If you rotate the API wallet in Hyperliquid, update it here before trading again.' : 'Use the Hyperliquid API wallet private key from app.hyperliquid.xyz/API. Never paste your MetaMask secret phrase or private key here.' const keyStatusHint = keyState === 'ok' ? 'API wallet saved. Recommended next step: keep size tiny and run your own first BTC test before treating the setup as production-ready.' : null // ── Hyperliquid key strip (shown above the settings card) ── const hlKeyStrip = (
{hlApiKeySet ? '✓' : '◇'}
Hyperliquid API wallet
{!isSubscribed ? 'Subscribe first to link your HL account' : hlApiKeySet && !apiKey ? <>Linked · {hlApiKeyMasked ?? '···'} · trade-only scope : 'Paste your API wallet private key — trade-only, never withdraw'}
{isSubscribed && hlApiKeySet && !apiKey && ( )} {isSubscribed && (!hlApiKeySet || apiKey) && ( <> { setApiKey(e.target.value); if (keyState !== 'idle') setKeyState('idle') }} placeholder="0x…" style={{ width: 220, fontFamily: 'var(--mono)', fontSize: 12, padding: '8px 10px' }} /> )}
{keyHint}
{keyStatusHint && (
{keyStatusHint}
)} {keyState === 'err' && (
{keyErr}
)}
) // ── Is the account fully configured? Bot only trades when every box is ticked. ── const missingSetup: string[] = [] if (!isSubscribed) missingSetup.push('subscribe to the bot') if (!hlApiKeySet) missingSetup.push('link a Hyperliquid API wallet') if (!tpConfigured) missingSetup.push('take-profit') if (!slConfigured) missingSetup.push('stop-loss') if (!budgetConfigured) missingSetup.push('daily budget') const botReady = missingSetup.length === 0 const setupSteps = [ { label: '1. Subscribe', done: isSubscribed }, { label: '2. Add HL API wallet', done: hlApiKeySet }, { label: '3. Save risk limits', done: tpConfigured && slConfigured && budgetConfigured }, ] const setupGuideMessage = !isSubscribed ? 'Start by subscribing with the connected wallet. That unlocks the bot profile for this address.' : botReadiness === 'ready' ? 'Your setup is verified. Before relying on automation, run one tiny BTC trade with your own wallet and Hyperliquid API wallet to confirm the live path end to end.' : !hlApiKeySet ? 'Next, paste the Hyperliquid API wallet private key you generated inside Hyperliquid. Do not paste your MetaMask private key or seed phrase here.' : 'Your exchange key is saved. Finish take-profit, stop-loss, and daily budget, then save changes. The backend still needs to verify this setup before we mark it ready.' return (
{setupSteps.map((step) => ( {step.done ? '✓ ' : '○ '} {step.label} ))}
{setupGuideMessage}
{hlKeyStrip}
{/* ─── Header bar ─── */}
Trading bot
Executes on Hyperliquid when a Trump post clears your filters.
{dirty && isSubscribed && ● Unsaved} {saveState === 'err' && {saveErr}} {!isSubscribed ? ( ) : ( <> {botReady ? '● Bot ready' : '● Bot paused'} )}
{/* ─── Setup-incomplete warning (persists until fully configured) ─── */} {isSubscribed && loadState === 'loaded' && !botReady && (
Setup incomplete — the bot will not open any trades.
Finish configuring: {missingSetup.join(' · ')}. Fill every required field below and save. The bot stays paused until every box is ticked.
)} {isSubscribed && loadState === 'loaded' && botReady && (
Setup complete on this page.
Wallet subscription, API wallet storage, and risk fields are all saved. Before you rely on automation, use your own account to run one tiny BTC test and confirm the live trade path behaves the way you expect.
)} {!isSubscribed ? (
Subscribe to activate automated trading. The bot signs positions on Hyperliquid using an API wallet scoped to trade-only — it can never withdraw. You pick the size, leverage, risk limits, and signal threshold below. {subState === 'err' &&
{subErr}
}
) : loadState !== 'loaded' ? (
Sign a one-time message with your wallet to load your bot settings and trade history. The signature is cached for 4 minutes so repeat visits won't prompt again.
{loadState === 'err' && {loadErr}}
) : (
{/* ─── EXECUTION ─── */}
Execution
Per-trade size Notional in USD. margin ≈ ${(settings.position_size_usd / settings.leverage).toFixed(2)} at {settings.leverage}×
$ updateSettings({ position_size_usd: Math.max(5, +e.target.value || 0) })} />
$5 – $10,000
Leverage Applied to every position
updateSettings({ leverage: +e.target.value })} />
25×50×
{settings.leverage}×
Min AI confidence Skip any signal below this score (0–100)
updateSettings({ min_confidence: +e.target.value })} />
050100
{settings.min_confidence}%
{/* ─── LIMITS ─── */}
Limits & risk
Daily budget * Stop opening new trades once the day's total notional reaches this (UTC). Required.
$ updateSettings({ daily_budget_usd: e.target.value === '' ? null : Math.max(0, +e.target.value) })} /> /day
{!budgetConfigured && Not configured}
Take profit * Auto-close when unrealised gain hits target. Required.
updateSettings({ take_profit_pct: e.target.value === '' ? null : +e.target.value })} /> % gain
{!tpConfigured && Not configured}
Stop loss * Auto-close when drawdown hits limit. Required.
updateSettings({ stop_loss_pct: e.target.value === '' ? null : +e.target.value })} /> % loss
{!slConfigured && Not configured}
{/* ─── SCHEDULE ─── */}
Active schedule
Only run in a window Bot ignores signals outside this range. Shown in your browser's local time.
{ setUseSchedule(v); setDirty(true) }} /> {useSchedule && (
From { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
Until { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
)}
{useSchedule && fromLocal && untilLocal && (() => { const fromMs = new Date(fromLocal).getTime() const untilMs = new Date(untilLocal).getTime() const nowMs = Date.now() if (isNaN(fromMs) || isNaN(untilMs) || untilMs <= fromMs) return null const inWindow = nowMs >= fromMs && nowMs < untilMs const durMs = untilMs - fromMs const durH = durMs / 3600000 const durLabel = durH < 1 ? `${Math.round(durH * 60)} min` : durH < 48 ? `${durH.toFixed(1)} h` : `${(durH / 24).toFixed(1)} d` return (
{inWindow ? '● In window now' : '○ Outside window'} Duration: {durLabel}
) })()}
)}
) } // ─── Trades table ───────────────────────────────────────────────────────────── export default function TradesPage() { const [trades, setTrades] = useState([]) const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(true) const [assetFilter, setAssetFilter] = useState('all') const [sideFilter, setSideFilter] = useState('all') useEffect(() => { Promise.all([ getTrades(100, 1).catch(() => []), getPosts(500, 1).catch(() => []), ]).then(([t, p]) => { setTrades(t); setPosts(p) }) .finally(() => setLoading(false)) }, []) const filtered = trades.filter(t => { if (assetFilter !== 'all' && t.asset !== assetFilter) return false if (sideFilter !== 'all' && t.side !== sideFilter) return false return true }) // Trades closed externally on HL may have null pnl_usd — exclude from aggregates. const priced = filtered.filter(t => t.pnl_usd !== null && t.pnl_usd !== undefined) const totalPnl = priced.reduce((s, t) => s + (t.pnl_usd ?? 0), 0) const wins = priced.filter(t => (t.pnl_usd ?? 0) > 0).length const losses = priced.length - wins const avgHold = filtered.length > 0 ? Math.round(filtered.reduce((s, t) => s + t.hold_seconds, 0) / filtered.length) : 0 return (

Trades

Bot configuration · execution history · P&L

{/* Bot config: settings + HL key */} {/* Stats */}
Total trades
{filtered.length}
Win rate
{priced.length ? ((wins/priced.length)*100).toFixed(1)+'%' : '—'}
{wins}W · {losses}L
Net P&L
{fmtMoney(totalPnl,{sign:true,decimals:0})}
Avg hold
{avgHold ? fmtHold(avgHold) : '—'}
{/* Filters */}
{(['all','BTC','ETH'] as const).map(a => ( ))}
{(['all','long','short'] as const).map(s => ( ))}
{loading &&
Loading…
} {!loading && (
{filtered.length === 0 && ( )} {filtered.map(t => { const tp = posts.find(p => p.id === t.trigger_post_id) const roi = ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1) return ( ) })}
AssetSideEntryExit HoldTrigger postP&L
No trades found
{t.asset}
{t.side==='long'?'↗ LONG':'↘ SHORT'} ${t.entry_price.toLocaleString()} ${t.exit_price.toLocaleString()} {fmtHold(t.hold_seconds)} {tp ? ( {tp.text.slice(0,60)}… ) : }
{t.pnl_usd === null || t.pnl_usd === undefined ? ( n/a ) : ( <> =0?'up':'down'}`} style={{ fontWeight:600, fontSize:14 }}> {fmtMoney(t.pnl_usd,{sign:true})} =0?'up':'down'}`} style={{ fontSize:11, opacity:0.7 }}>{fmtPct(roi)} )}
)}
) }