d01adc4790
for Performance accent
User flagged the macro index box on the overview page as feeling
disjointed. Reshaped it from three stacked elements (band → track →
scale) into a single bordered card so it reads as ONE component.
- JSX: wrap the three pieces in <div className="overview-macro-card">
- CSS: new .overview-macro-card with tone-coloured rim (bull/bear/neutral)
- Solid neutral-gray filled needle (was a hollow ring — looked like a
placeholder); tone-coloured background + white inner ring + double
shadow so it stands out on any gradient position
- Removed the .overview-score-fill overlay — the gradient already
encodes the spectrum; layering an opaque fill obscured it near 0
- Thinner track (14px vs 22px), tighter scale labels, smaller pill
- Added "TODAY · 8 INDICATORS" stamp next to the title — gives users
a quick anchor of what they're looking at + freshness
Plus: dark-mode override for .overview-stat-card.accent (the Performance
card). It was using a cream gradient that floated as a glaring
out-of-theme block on dark mode. Mirrored the existing .kpi.accent dark
treatment so it stays visually grouped with the rest of the dashboard.
Also includes the in-flight overview rewrite from the other AI tool
(legacy /btc redirect to /macro, middleware.ts replacing proxy.ts for
Next.js routing, refactored several dashboard panels). TypeScript clean,
production build passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
999 lines
48 KiB
TypeScript
999 lines
48 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback } from 'react'
|
||
import { useLocale } from 'next-intl'
|
||
import { useAccount, useConnect, useSignMessage } from 'wagmi'
|
||
import {
|
||
getUserPublic,
|
||
getUser,
|
||
setUserSettings,
|
||
setHlApiKey,
|
||
setManualWindow,
|
||
subscribe,
|
||
type UserSettings,
|
||
} from '@/lib/api'
|
||
import { useDashboardStore } from '@/store/dashboard'
|
||
import {
|
||
signRequest,
|
||
getOrCreateViewEnvelope,
|
||
getCachedViewEnvelope,
|
||
} from '@/lib/signedRequest'
|
||
import { walletErrorLabel } from '@/lib/walletError'
|
||
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
|
||
import { confirmSign } from '@/components/wallet/SignConfirmSheet'
|
||
|
||
// 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, sys2_leverage: 2,
|
||
sys2_mode: 'standard',
|
||
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 ─────────────────────────────────────────────────────────
|
||
export default function BotConfigPanel() {
|
||
const locale = useLocale()
|
||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||
const { address, isConnected } = useAccount()
|
||
const { connectAsync, connectors } = useConnect()
|
||
const { signMessageAsync } = useSignMessage()
|
||
const {
|
||
isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness,
|
||
setSubscribed, setBotReadiness, setHlApiKeySet,
|
||
} = useDashboardStore()
|
||
|
||
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
|
||
// Tracks whether the SERVER already has values for these required fields.
|
||
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. `subPaperChoice` is the user's selection on the
|
||
// pre-subscribe form ("paper" or "live"); written to Subscription.paper_mode
|
||
// when the signed subscribe goes through.
|
||
const [subState, setSubState] = useState<'idle'|'signing'|'saving'|'err'>('idle')
|
||
const [subErr, setSubErr] = useState('')
|
||
const [subPaperChoice, setSubPaperChoice] = useState<'paper' | 'live'>('paper')
|
||
|
||
// Manual window (convex-strategy "enable for N hours" override).
|
||
// null = no override active; ISO string = bot armed until that UTC time.
|
||
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) // forces countdown re-render
|
||
|
||
// Mounted flag: avoid SSR/CSR mismatch since wagmi state differs between the two.
|
||
const [mounted, setMounted] = useState(false)
|
||
useEffect(() => { setMounted(true) }, [])
|
||
|
||
// Settings-load state. 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('')
|
||
const [connectErr, setConnectErr] = 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
|
||
manual_window_until?: string | null
|
||
}) {
|
||
setSubscribed(u.active)
|
||
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)
|
||
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
|
||
const cached = getCachedViewEnvelope('view_user', address)
|
||
if (!cached) return
|
||
const u = await getUser(address, cached).catch(() => null)
|
||
if (!u || cancelled) return
|
||
applyUserPayload(u)
|
||
setLoadState('loaded')
|
||
})()
|
||
return () => { cancelled = true }
|
||
}, [address, isConnected, mounted]) // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
async function handleLoadSettings() {
|
||
if (!address) return
|
||
setLoadErr(''); setLoadState('loading')
|
||
try {
|
||
const ok = await confirmSign({
|
||
label: isZh ? '查看账户设置' : 'View account settings',
|
||
description: isZh
|
||
? '读取你在 Trump Alpha 的订阅状态、风控参数和 API Key 状态。签名仅用于身份验证,不做任何链上操作。'
|
||
: 'Read your Trump Alpha subscription state, risk settings, and API key status. The signature is only for identity verification. No on-chain action is performed.',
|
||
})
|
||
if (!ok) { setLoadState('idle'); return }
|
||
const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync })
|
||
const u = await getUser(address, env)
|
||
applyUserPayload(u)
|
||
setLoadState('loaded')
|
||
} catch (e: unknown) {
|
||
setLoadErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120))
|
||
setLoadState('err')
|
||
}
|
||
}
|
||
|
||
async function refreshUserState(wallet: string) {
|
||
const pub = await getUserPublic(wallet.toLowerCase())
|
||
setSubscribed(pub.active)
|
||
setHlApiKeySet(pub.hl_api_key_set)
|
||
setBotReadiness(pub.hl_api_key_set ? 'saved' : 'unknown')
|
||
return pub
|
||
}
|
||
|
||
function updateSettings(patch: Partial<UserSettings>) {
|
||
setSettings(s => ({ ...s, ...patch }))
|
||
setDirty(true)
|
||
}
|
||
|
||
async function handleSubscribe() {
|
||
if (!address) return
|
||
const paperMode = subPaperChoice === 'paper'
|
||
const ok = await confirmSign({
|
||
label: paperMode
|
||
? (isZh ? '订阅 Trump Alpha(模拟模式)' : 'Subscribe to Trump Alpha (paper mode)')
|
||
: (isZh ? '订阅 Trump Alpha(真实资金)' : 'Subscribe to Trump Alpha (live capital)'),
|
||
description: paperMode
|
||
? (isZh
|
||
? '注册为 Paper 模式订阅者。机器人开仓不涉及真实资金,仅供测试。'
|
||
: 'Register as a paper-mode subscriber. Trades are simulated and do not touch real funds.')
|
||
: (isZh
|
||
? '注册为正式订阅者。完成后需配置 Hyperliquid API Key 才能实际开仓。'
|
||
: 'Register as a live subscriber. You still need to configure a Hyperliquid API key before any real trade can open.'),
|
||
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)
|
||
void refreshUserState(address)
|
||
setSubState('idle')
|
||
setTpConfigured(false)
|
||
setSlConfigured(false)
|
||
setBudgetConfigured(false)
|
||
setLoadState('loaded')
|
||
} catch (e: unknown) {
|
||
setSubErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 100))
|
||
setSubState('err')
|
||
}
|
||
}
|
||
|
||
async function handleSaveSettings() {
|
||
if (!address) return
|
||
const ok = await confirmSign({
|
||
label: isZh ? '保存交易风控参数' : 'Save trading risk settings',
|
||
description: isZh
|
||
? '保存杠杆、仓位大小、止盈止损比例和每日预算设置。签名后立即生效。'
|
||
: 'Save leverage, position size, take-profit, stop-loss, and daily budget settings. Changes apply immediately after signing.',
|
||
})
|
||
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(isZh ? '请选择开始和结束时间' : 'Pick both a start and end time'); setSaveState('err'); return
|
||
}
|
||
if (new Date(schedUntil).getTime() <= new Date(schedFrom).getTime()) {
|
||
setSaveErr(isZh ? '结束时间必须晚于开始时间' : 'End must be after start'); setSaveState('err'); return
|
||
}
|
||
}
|
||
const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd
|
||
if (tp == null || tp < 0.1 || tp > 50) { setSaveErr(isZh ? '止盈必须在 0.1% 到 50% 之间' : 'Take profit must be 0.1 – 50%'); setSaveState('err'); return }
|
||
if (sl == null || sl < 0.1 || sl > 50) { setSaveErr(isZh ? '止损必须在 0.1% 到 50% 之间' : 'Stop loss must be 0.1 – 50%'); setSaveState('err'); return }
|
||
if (bd == null || bd <= 0 || bd > 100000){ setSaveErr(isZh ? '每日预算必须大于 0 美元' : '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) {
|
||
setSaveErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 100))
|
||
setSaveState('err')
|
||
}
|
||
}
|
||
|
||
async function handleSaveKey() {
|
||
if (!address || !apiKey.trim()) return
|
||
const trimmed = apiKey.trim()
|
||
if (!trimmed.startsWith('0x') || trimmed.length !== 66) {
|
||
setKeyErr(isZh ? '必须是 0x 开头加 64 位十六进制字符' : 'Must be 0x + 64 hex chars'); setKeyState('err'); return
|
||
}
|
||
const ok = await confirmSign({
|
||
label: isZh ? '绑定 Hyperliquid API Key' : 'Link Hyperliquid API key',
|
||
description: isZh
|
||
? 'API Key 将加密存储在服务端,用于机器人在 Hyperliquid 上代替你下单。签名用于验证是你本人操作。'
|
||
: 'The API key is stored encrypted on the server and used by the bot to place trades on Hyperliquid for you. The signature confirms this action came from you.',
|
||
danger: true,
|
||
})
|
||
if (!ok) return
|
||
setKeyErr(''); setKeyState('signing')
|
||
try {
|
||
const env = await signRequest({ action: 'set_hl_api_key', wallet: address, body: { api_key: trimmed }, signMessageAsync })
|
||
setKeyState('saving')
|
||
const res = await setHlApiKey(env, trimmed)
|
||
setHlApiKeySet(true, res.masked_key)
|
||
setBotReadiness('saved')
|
||
void refreshUserState(address)
|
||
setApiKey('')
|
||
setKeyState('ok')
|
||
} catch (e: unknown) {
|
||
setKeyErr(walletErrorLabel(e, isZh ? '已取消' : '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])
|
||
|
||
// ── Manual-window: arm for N hours, or clear (hours = 0) ──────────────────
|
||
async function handleManualWindow(hours: number) {
|
||
if (!address) 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, isZh ? '已取消' : 'Cancelled', 100))
|
||
setMwState('err')
|
||
}
|
||
}
|
||
|
||
// Countdown re-render every 15s while a manual window is armed.
|
||
useEffect(() => {
|
||
if (!manualUntil) return
|
||
const id = setInterval(() => setMwTick(t => t + 1), 15000)
|
||
return () => clearInterval(id)
|
||
}, [manualUntil])
|
||
|
||
if (!mounted || !isConnected) {
|
||
return (
|
||
<div className="card" style={{ padding: 32, textAlign: 'center', marginBottom: 28 }}>
|
||
<p style={{ color: 'var(--ink-3)', marginBottom: 16 }}>
|
||
{isZh ? '连接钱包后即可配置机器人并查看你的交易。' : 'Connect your wallet to configure the bot and view your trades.'}
|
||
</p>
|
||
<button className="btn amber" onClick={() => { void handleConnectWallet() }}>{isZh ? '连接钱包' : 'Connect wallet'}</button>
|
||
{connectErr ? (
|
||
<p style={{ fontSize: 12, color: 'var(--down)', margin: '12px 0 0' }}>{connectErr}</p>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── small local helpers ───────────────────────────────────────────────────
|
||
const Switch = ({ on, onChange, tone = 'amber' }: { on: boolean; onChange: (v: boolean) => void; tone?: 'amber' | 'up' | 'down' }) => (
|
||
<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 saveBtnLabel =
|
||
saveState === 'signing' ? (isZh ? '钱包签名中…' : 'Sign in wallet…')
|
||
: saveState === 'saving' ? (isZh ? '保存中…' : 'Saving…')
|
||
: saveState === 'ok' ? (isZh ? '✓ 已保存' : '✓ Saved')
|
||
: (isZh ? '保存更改' : 'Save changes')
|
||
|
||
const keyHint =
|
||
hlApiKeySet && !apiKey
|
||
? (isZh
|
||
? '已保存在你的机器人配置里。如果你在 Hyperliquid 轮换了 API 钱包,重新交易前请先在这里更新。'
|
||
: 'Saved in your bot profile. If you rotate the API wallet in Hyperliquid, update it here before trading again.')
|
||
: (isZh
|
||
? '请使用 app.hyperliquid.xyz/API 生成的 Hyperliquid API 钱包私钥。不要在这里粘贴 MetaMask 助记词或私钥。'
|
||
: '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'
|
||
? (isZh
|
||
? 'API 钱包已保存。建议下一步先用极小仓位跑一次你自己的 BTC 测试,再把这套配置当成生产可用。'
|
||
: 'API wallet saved. Recommended next step: keep size tiny and run your own first BTC test before treating the setup as production-ready.')
|
||
: null
|
||
|
||
function ScopePill({
|
||
label,
|
||
tone = 'amber',
|
||
}: {
|
||
label: string
|
||
tone?: 'amber' | 'up' | 'violet'
|
||
}) {
|
||
return (
|
||
<span className={`chip ${tone}`} style={{ fontSize: 10, padding: '2px 8px', marginLeft: 8 }}>
|
||
{label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// ── Hyperliquid key strip ─────────────────────────────────────────────────
|
||
const hlKeyStrip = (
|
||
<div className="card" style={{ padding: '16px 20px', marginBottom: 16, display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', gap: 16 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
|
||
<div style={{ width: 32, height: 32, borderRadius: 8, background: hlApiKeySet ? 'var(--up-soft)' : 'var(--bg-sunk)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, color: hlApiKeySet ? 'var(--up)' : 'var(--ink-4)' }}>
|
||
{hlApiKeySet ? '✓' : '◇'}
|
||
</div>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 600 }}>Hyperliquid API wallet</div>
|
||
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 1 }}>
|
||
{!isSubscribed
|
||
? 'Subscribe first to link your HL account'
|
||
: hlApiKeySet && !apiKey
|
||
? <><span>Linked · </span><span className="mono">{hlApiKeyMasked ?? '···'}</span><span> · trade-only scope</span></>
|
||
: 'Paste your API wallet private key — trade-only, never withdraw'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
{isSubscribed && hlApiKeySet && !apiKey && (
|
||
<button className="btn ghost" style={{ padding: '6px 14px', fontSize: 12 }} onClick={() => setApiKey(' ')}>Rotate</button>
|
||
)}
|
||
{isSubscribed && (!hlApiKeySet || apiKey) && (
|
||
<>
|
||
<input
|
||
value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
|
||
onChange={e => { setApiKey(e.target.value); if (keyState !== 'idle') setKeyState('idle') }}
|
||
placeholder="0x…"
|
||
style={{ width: 220, fontFamily: 'var(--mono)', fontSize: 12, padding: '8px 10px' }}
|
||
/>
|
||
<button
|
||
className={`btn ${keyState === 'ok' ? 'ghost' : 'amber'}`}
|
||
style={{ padding: '8px 14px', fontSize: 12 }}
|
||
onClick={handleSaveKey}
|
||
disabled={keyState === 'signing' || keyState === 'saving' || !apiKey.trim()}
|
||
>
|
||
{keyState === 'signing' ? 'Sign…' : keyState === 'saving' ? 'Saving…' : keyState === 'ok' ? '✓' : 'Save'}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div style={{ gridColumn: '1 / -1', fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.55 }}>{keyHint}</div>
|
||
{keyStatusHint && (
|
||
<div style={{ gridColumn: '1 / -1', fontSize: 11, color: 'var(--up)', lineHeight: 1.55 }}>{keyStatusHint}</div>
|
||
)}
|
||
{keyState === 'err' && (
|
||
<div style={{ gridColumn: '1 / -1', fontSize: 12, color: 'var(--down)' }}>{keyErr}</div>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
// ── Setup readiness ───────────────────────────────────────────────────────
|
||
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 (
|
||
<div style={{ marginBottom: 28 }}>
|
||
{/* ── Setup progress banner ── */}
|
||
<div
|
||
className="card"
|
||
style={{
|
||
padding: '14px 18px', marginBottom: 12,
|
||
background: botReady ? 'var(--up-soft)' : 'var(--amber-soft)',
|
||
borderColor: botReady
|
||
? 'color-mix(in oklab, var(--up) 18%, var(--line))'
|
||
: 'color-mix(in oklab, var(--amber) 22%, var(--line))',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||
{setupSteps.map((step) => (
|
||
<span key={step.label} className={`chip ${step.done ? 'up' : ''}`} style={{ fontSize: 11 }}>
|
||
{step.done ? '✓ ' : '○ '}{step.label}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.55 }}>{setupGuideMessage}</div>
|
||
</div>
|
||
|
||
{hlKeyStrip}
|
||
|
||
{/* ── Manual window (convex-strategy override) ── */}
|
||
{isSubscribed && (() => {
|
||
const untilMs = manualUntil ? new Date(manualUntil).getTime() : 0
|
||
// Touch the tick counter so countdown re-renders even if `manualUntil` is unchanged.
|
||
void mwTick
|
||
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 busy = mwState === 'signing' || mwState === 'saving'
|
||
|
||
return (
|
||
<div
|
||
className="card"
|
||
style={{
|
||
padding: '14px 18px',
|
||
marginBottom: 16,
|
||
background: armed ? 'var(--up-soft)' : undefined,
|
||
borderColor: armed ? 'color-mix(in oklab, var(--up) 22%, var(--line))' : undefined,
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||
{armed ? '● Manually armed' : '○ Manual window'}
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
|
||
{armed
|
||
? <>Bot is live for <strong>{remainingLabel}</strong> more — overrides any schedule.</>
|
||
: 'Arm the bot for a specific window. Best for catalysts (CPI, FOMC, news drops).'}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||
{!armed && (
|
||
<>
|
||
{[1, 4, 24].map(h => (
|
||
<button
|
||
key={h}
|
||
className="btn ghost"
|
||
style={{ padding: '6px 12px', fontSize: 12 }}
|
||
onClick={() => handleManualWindow(h)}
|
||
disabled={busy}
|
||
>
|
||
{busy && mwState === 'signing' ? 'Sign…' : `${h}h`}
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
{armed && (
|
||
<button
|
||
className="btn ghost"
|
||
style={{ padding: '6px 14px', fontSize: 12 }}
|
||
onClick={() => handleManualWindow(0)}
|
||
disabled={busy}
|
||
>
|
||
{busy ? 'Sign…' : 'Disarm'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{mwState === 'err' && (
|
||
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}>{mwErr}</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* ── Main settings card ── */}
|
||
<div className="card" id="bot-config-panel" style={{ padding: 0, overflow: 'hidden' }}>
|
||
{/* Header bar */}
|
||
<div style={{ padding: '20px 24px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid var(--line)' }}>
|
||
<div>
|
||
<div style={{ fontSize: 16, fontWeight: 600, letterSpacing: '-0.01em' }}>Trading bot</div>
|
||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>Controls Trump Signal entries, Macro Vibes BTC management, and shared execution limits on Hyperliquid.</div>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||
{dirty && isSubscribed && <span style={{ fontSize: 12, color: 'var(--amber-ink)' }}>● Unsaved</span>}
|
||
{saveState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{saveErr}</span>}
|
||
{!isSubscribed ? (
|
||
// Subscribe button moved into the body to live alongside the
|
||
// paper-vs-live choice. Header keeps only status chips.
|
||
<span className="chip" style={{ fontSize: 11, background: 'var(--bg-sunk)' }}>
|
||
○ Not subscribed
|
||
</span>
|
||
) : (
|
||
<>
|
||
<span className={`chip ${botReady ? 'up' : 'down'}`} style={{ fontSize: 11 }}>
|
||
{botReady ? '● Bot ready' : '● Bot paused'}
|
||
</span>
|
||
<button
|
||
className={`btn ${saveState === 'ok' ? 'ghost' : 'amber'}`}
|
||
style={{ padding: '8px 18px', fontSize: 13 }}
|
||
onClick={handleSaveSettings}
|
||
disabled={!dirty || saveState === 'signing' || saveState === 'saving'}
|
||
>
|
||
{saveBtnLabel}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Setup-incomplete warning */}
|
||
{isSubscribed && loadState === 'loaded' && !botReady && (
|
||
<div style={{ padding: '14px 24px', background: 'var(--down-soft)', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||
<span style={{ fontSize: 16, color: 'var(--down)', lineHeight: 1 }}>⚠</span>
|
||
<div style={{ fontSize: 13, lineHeight: 1.55, color: 'var(--ink)' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: 2 }}>Setup incomplete — the bot will not open any trades.</div>
|
||
<div style={{ color: 'var(--ink-3)' }}>
|
||
Finish configuring: <span style={{ color: 'var(--down)', fontWeight: 500 }}>{missingSetup.join(' · ')}</span>. Fill every required field below and save. The bot stays paused until every box is ticked.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Setup-complete banner */}
|
||
{isSubscribed && loadState === 'loaded' && botReady && (
|
||
<div style={{ padding: '14px 24px', background: 'var(--up-soft)', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'flex-start', gap: 12 }}>
|
||
<span style={{ fontSize: 16, color: 'var(--up)', lineHeight: 1 }}>✓</span>
|
||
<div style={{ fontSize: 13, lineHeight: 1.55, color: 'var(--ink)' }}>
|
||
<div style={{ fontWeight: 600, marginBottom: 2 }}>Setup complete on this page.</div>
|
||
<div style={{ color: 'var(--ink-3)' }}>
|
||
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.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Body */}
|
||
{!isSubscribed ? (
|
||
<div style={{ padding: 28 }}>
|
||
<div style={{ color: 'var(--ink-3)', fontSize: 13, lineHeight: 1.65, marginBottom: 18 }}>
|
||
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.
|
||
</div>
|
||
|
||
{/* ── Paper-vs-Live choice (P0.2) ────────────────────────────
|
||
New users land on PAPER by default. Forces a conscious
|
||
opt-in to live trading rather than auto-routing real money. */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 16 }}>
|
||
{([
|
||
{
|
||
key: 'paper',
|
||
title: '📝 Start in paper mode',
|
||
tag: 'Recommended',
|
||
body: 'Trades are simulated end-to-end. No funds at risk, no HL key needed. Promote to live anytime.',
|
||
},
|
||
{
|
||
key: 'live',
|
||
title: '💰 Start live',
|
||
tag: 'Real money',
|
||
body: 'Bot trades real positions on Hyperliquid. Requires an HL API key. Risk = your full position size.',
|
||
},
|
||
] as const).map(opt => {
|
||
const active = subPaperChoice === opt.key
|
||
return (
|
||
<button
|
||
key={opt.key}
|
||
onClick={() => setSubPaperChoice(opt.key)}
|
||
style={{
|
||
textAlign: 'left', cursor: 'pointer',
|
||
padding: '14px 16px', borderRadius: 10,
|
||
background: active ? 'var(--up-soft)' : 'var(--bg-sunk)',
|
||
border: active
|
||
? '1px solid color-mix(in oklab, var(--up) 30%, var(--line))'
|
||
: '1px solid var(--line)',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
|
||
<span style={{ fontSize: 13, fontWeight: 600 }}>{opt.title}</span>
|
||
<span style={{
|
||
fontSize: 10, padding: '2px 8px', borderRadius: 4,
|
||
background: opt.key === 'paper' ? 'var(--up-soft)' : 'var(--amber-soft)',
|
||
color: opt.key === 'paper' ? 'var(--up)' : 'var(--amber-ink)',
|
||
fontWeight: 600,
|
||
}}>{opt.tag}</span>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--ink-3)', lineHeight: 1.5 }}>
|
||
{opt.body}
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* The subscribe button moves down here so paper choice is
|
||
resolved before the wallet popup. */}
|
||
<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…'
|
||
: subPaperChoice === 'paper'
|
||
? 'Subscribe in paper mode'
|
||
: 'Subscribe (live trading)'}
|
||
</button>
|
||
{subState === 'err' && <span style={{ fontSize: 12, color: 'var(--down)' }}>{subErr}</span>}
|
||
</div>
|
||
</div>
|
||
) : loadState !== 'loaded' ? (
|
||
<div style={{ padding: 28 }}>
|
||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 16 }}>
|
||
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.
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<button className="btn amber" 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>
|
||
) : (
|
||
<div style={{ padding: '8px 24px 24px' }}>
|
||
{/* ── EXECUTION ── */}
|
||
<div className="section-head" id="trump-settings-panel">
|
||
<span className="section-head-label">Trump Signal settings</span>
|
||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--ink-3)', margin: '4px 0 10px' }}>
|
||
Event-driven trade sizing, leverage, and signal threshold for Trump-triggered entries.
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<div className="form-row-label">
|
||
Per-trade size <ScopePill label="Trump only" />
|
||
<span className="hint">Notional in USD. margin ≈ ${(settings.position_size_usd / settings.leverage).toFixed(2)} at {settings.leverage}×</span>
|
||
</div>
|
||
<div className="form-row-control">
|
||
<div className="num-field">
|
||
<span className="prefix">$</span>
|
||
<input type="number" min={5} max={10000} step={5} value={settings.position_size_usd}
|
||
onChange={e => updateSettings({ position_size_usd: Math.max(5, +e.target.value || 0) })} />
|
||
</div>
|
||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>$5 – $10,000</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<div className="form-row-label">
|
||
Leverage <span style={{ opacity: 0.6 }}>(Trump / System 1)</span> <ScopePill label="Trump only" />
|
||
<span className="hint">Event-driven scalp positions</span>
|
||
</div>
|
||
<div className="form-row-control">
|
||
<div className="slider-field">
|
||
<input type="range" min={1} max={50} value={settings.leverage}
|
||
onChange={e => updateSettings({ leverage: +e.target.value })} />
|
||
<div className="ticks"><span>1×</span><span>25×</span><span>50×</span></div>
|
||
</div>
|
||
<span className="slider-readout">{settings.leverage}×</span>
|
||
</div>
|
||
</div>
|
||
|
||
{(() => {
|
||
const aggressive = settings.sys2_mode === 'aggressive'
|
||
return (
|
||
<>
|
||
<div className="section-head" id="btc-settings-panel" style={{ marginTop: 20 }}>
|
||
<span className="section-head-label">Macro Vibes settings</span>
|
||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--ink-3)', margin: '4px 0 10px' }}>
|
||
Manage-only BTC mode, leverage, and pyramiding behavior for the Macro Vibes system.
|
||
</div>
|
||
<div className="form-row">
|
||
<div className="form-row-label">
|
||
BTC strategy mode <span style={{ opacity: 0.6 }}>(System 2)</span> <ScopePill label="BTC only" tone="up" />
|
||
<span className="hint">
|
||
Choose how hard the BTC system presses winners. Standard is calmer. Aggressive adds more 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,
|
||
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',
|
||
cursor: 'pointer',
|
||
}}>
|
||
Standard
|
||
</button>
|
||
<button type="button"
|
||
onClick={() => updateSettings({ sys2_mode: 'aggressive' })}
|
||
aria-pressed={aggressive}
|
||
style={{
|
||
padding: '8px 16px',
|
||
fontSize: 12,
|
||
fontWeight: 700,
|
||
borderRadius: 999,
|
||
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',
|
||
cursor: 'pointer',
|
||
}}>
|
||
🔥 Aggressive
|
||
</button>
|
||
</div>
|
||
{aggressive && (
|
||
<div className="settings-note warn" style={{ marginTop: 6 }}>
|
||
High-risk sleeve. Uses more leverage, pyramids harder, and gives back more off the peak. Best treated as a separate risk bucket.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
})()}
|
||
|
||
{(() => {
|
||
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
|
||
const e1 = aggressive ? '¼' : '⅓'
|
||
const e2 = aggressive ? '¼' : '⅓'
|
||
return (
|
||
<div className="form-row">
|
||
<div className="form-row-label">
|
||
BTC bottom leverage <span style={{ opacity: 0.6 }}>(System 2)</span> <ScopePill label="BTC only" tone="up" />
|
||
<span className="hint">
|
||
Separate from Trump leverage. The bot de-risks in stages before the exchange liquidation line.
|
||
</span>
|
||
</div>
|
||
<div className="form-row-control">
|
||
<div className="slider-field">
|
||
<input type="range" min={1} max={10} value={lev}
|
||
onChange={e => updateSettings({ sys2_leverage: +e.target.value })} />
|
||
<div className="ticks"><span>1×</span><span>5×</span><span>10×</span></div>
|
||
</div>
|
||
<span className="slider-readout">{lev}×</span>
|
||
<div className={`settings-note ${risky ? 'warn' : ''}`} style={{ marginTop: 6 }}>
|
||
At {lev}× it sheds {e1} near −{(prot * 0.6).toFixed(0)}%, {e2} near −{(prot * 0.8).toFixed(0)}%, and is fully out by −{prot.toFixed(0)}%.
|
||
Exchange liquidation would be around −{liq.toFixed(0)}%.
|
||
{' '}
|
||
{risky
|
||
? 'Above 2×, a normal bull-market correction can push you out early.'
|
||
: 'This is wide enough to survive a normal correction; extra upside should come from pyramiding, not leverage.'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
<div className="form-row">
|
||
<div className="form-row-label">
|
||
Min AI confidence <ScopePill label="Trump only" />
|
||
<span className="hint">Skip any signal below this score (0–100)</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>
|
||
|
||
{/* ── LIMITS ── */}
|
||
<div className="section-head" id="global-settings-panel" style={{ marginTop: 20 }}>
|
||
<span className="section-head-label">Global risk limits</span>
|
||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||
</div>
|
||
<div style={{ fontSize: 12, color: 'var(--ink-3)', margin: '4px 0 10px' }}>
|
||
Shared safety rules that cap or gate automated trading across the account.
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<div className="form-row-label">
|
||
Daily budget <span style={{ color: 'var(--down)' }}>*</span> <ScopePill label="Global" tone="violet" />
|
||
<span className="hint">Stop opening new trades once the day's total notional reaches this (UTC). Required.</span>
|
||
</div>
|
||
<div className="form-row-control">
|
||
<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>
|
||
{!budgetConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<div className="form-row-label">
|
||
Take profit <span style={{ color: 'var(--down)' }}>*</span> <ScopePill label="Trump only" />
|
||
<span className="hint">Auto-close when unrealised gain hits target. Required.</span>
|
||
</div>
|
||
<div className="form-row-control">
|
||
<div className="num-field">
|
||
<input type="number" min={0.1} max={50} step={0.1}
|
||
value={settings.take_profit_pct ?? ''}
|
||
onChange={e => updateSettings({ take_profit_pct: e.target.value === '' ? null : +e.target.value })} />
|
||
<span className="suffix">% gain</span>
|
||
</div>
|
||
{!tpConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<div className="form-row-label">
|
||
Stop loss <span style={{ color: 'var(--down)' }}>*</span> <ScopePill label="Trump only" />
|
||
<span className="hint">Auto-close when drawdown hits limit. Required.</span>
|
||
</div>
|
||
<div className="form-row-control">
|
||
<div className="num-field">
|
||
<input type="number" min={0.1} max={50} step={0.1}
|
||
value={settings.stop_loss_pct ?? ''}
|
||
onChange={e => updateSettings({ stop_loss_pct: e.target.value === '' ? null : +e.target.value })} />
|
||
<span className="suffix">% loss</span>
|
||
</div>
|
||
{!slConfigured && <span style={{ fontSize: 11, color: 'var(--down)' }}>Not configured</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── SCHEDULE ── */}
|
||
<div className="section-head" style={{ marginTop: 20 }}>
|
||
<span className="section-head-label">Active schedule</span>
|
||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||
</div>
|
||
|
||
<div className="form-row">
|
||
<div className="form-row-label">
|
||
Only run in a window
|
||
<span className="hint">Bot ignores signals outside this range. Shown in your browser's local time.</span>
|
||
</div>
|
||
<div className="form-row-control" style={{ flexWrap: 'wrap' }}>
|
||
<Switch on={useSchedule} onChange={(v) => { setUseSchedule(v); setDirty(true) }} />
|
||
{useSchedule && (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||
<div className="num-field">
|
||
<span className="prefix">From</span>
|
||
<input type="datetime-local" value={fromLocal}
|
||
onChange={e => { setFromLocal(e.target.value); setDirty(true) }}
|
||
style={{ width: 190 }} />
|
||
</div>
|
||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>→</span>
|
||
<div className="num-field">
|
||
<span className="prefix">Until</span>
|
||
<input type="datetime-local" value={untilLocal}
|
||
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }}
|
||
style={{ width: 190 }} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{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 durH = (untilMs - fromMs) / 3600000
|
||
const durLabel = durH < 1 ? `${Math.round(durH * 60)} min`
|
||
: durH < 48 ? `${durH.toFixed(1)} h`
|
||
: `${(durH / 24).toFixed(1)} d`
|
||
return (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '200px 1fr', gap: 24, padding: '0 0 14px' }}>
|
||
<div />
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 12 }}>
|
||
<span className={`chip ${inWindow ? 'up' : ''}`} style={{ fontSize: 11 }}>
|
||
{inWindow ? '● In window now' : '○ Outside window'}
|
||
</span>
|
||
<span style={{ color: 'var(--ink-4)' }}>Duration: {durLabel}</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|