KOL count: 29 → 25 across marketing/SEO copy

Backend KOL_FEEDS trimmed from 29 to 25 (dead feeds removed).
Sync all hardcoded count mentions:
- layout.tsx JSON-LD, page.tsx (metric + comparison + copy)
- kol/page.tsx, KolPageClient.tsx ("and 26 more" → "and 22 more")
- glossary/page.tsx, opengraph-image.tsx
- public/llms.txt, llms-full.txt
- drop removed KOLs (Dragonfly Capital, Nic Carter) from named lists

Bundles other in-flight frontend work already in the working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
k
2026-06-09 22:55:27 +08:00
parent 9e0f6554cb
commit 4c3c8c6f87
57 changed files with 3464 additions and 1855 deletions
+295 -65
View File
@@ -6,9 +6,11 @@ import { useAccount, useConnect, useSignMessage } from 'wagmi'
import {
getUserPublic,
getUser,
getTelegramStatus,
setUserSettings,
setHlApiKey,
setManualWindow,
setAutoTrade,
subscribe,
type UserSettings,
} from '@/lib/api'
@@ -30,18 +32,27 @@ const DEFAULT_SETTINGS: UserSettings = {
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 `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
// 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
const d = new Date(v)
if (isNaN(d.getTime())) return null
return d.toISOString()
// 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() {
@@ -51,7 +62,7 @@ export default function BotConfigPanel() {
const { signMessageAsync } = useSignMessage()
const {
isSubscribed, hlApiKeySet, hlApiKeyMasked,
setSubscribed, setBotReadiness, setHlApiKeySet,
setSubscribed, setBotReadiness, setHlApiKeySet, setPaperMode: setPaperModeStore,
} = useDashboardStore()
const [settings, setSettings] = useState<UserSettings>(DEFAULT_SETTINGS)
@@ -75,24 +86,66 @@ export default function BotConfigPanel() {
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) {
@@ -126,6 +179,7 @@ export default function BotConfigPanel() {
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
@@ -141,11 +195,8 @@ export default function BotConfigPanel() {
if (!address) return
setLoadErr(''); setLoadState('loading')
try {
const ok = await confirmSign({
label: 'View account settings',
description: '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 }
// 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)
@@ -161,9 +212,34 @@ export default function BotConfigPanel() {
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)
@@ -202,7 +278,7 @@ export default function BotConfigPanel() {
if (!address) return
const ok = await confirmSign({
label: 'Save trading settings',
description: 'Settings are saved to the server and apply to all new trades from this point forward. Open positions are not affected.',
description: 'Applied to all new trades going forward. Open positions are not affected.',
})
if (!ok) return
setSaveErr(''); setSaveState('signing')
@@ -213,7 +289,11 @@ export default function BotConfigPanel() {
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 }
// Cross-midnight windows (e.g. 22:0002:00) are VALID — the backend's
// _is_in_active_window treats af_t > au_t as a wrap-around window
// (now >= from OR now <= until). Only reject when start === end, which
// would describe a zero-length (or full-day-ambiguous) window.
if (new Date(schedUntil).getTime() === new Date(schedFrom).getTime()) { setSaveErr('Start and end cant be the same time'); setSaveState('err'); return }
}
const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd
if (trumpOn) {
@@ -244,7 +324,7 @@ export default function BotConfigPanel() {
}
const ok = await confirmSign({
label: 'Link Hyperliquid API key',
description: 'The API key is stored encrypted on the server and used by the bot to place trades on Hyperliquid for you.',
description: 'Stored encrypted on the server. Used by the bot to open and close positions only — no withdrawal access.',
danger: true,
})
if (!ok) return
@@ -304,7 +384,7 @@ export default function BotConfigPanel() {
if (!address) return
const ok = await confirmSign({
label: 'Switch to live trading',
description: 'Your subscription will change from paper mode to live. For your safety, Auto-Trade is turned OFF on this switch — you must re-enable it explicitly while live. No trade opens immediately; you still need to add a Hyperliquid API key first.',
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
@@ -360,10 +440,9 @@ export default function BotConfigPanel() {
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 trading bot</div>
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 24 }}>
The bot places trades on Hyperliquid with a trade-only API key it can never withdraw funds.
Start in paper mode to try it safely, or choose Live if you already have a Hyperliquid API key.
<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 */}
@@ -421,8 +500,8 @@ export default function BotConfigPanel() {
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.65, marginBottom: 20 }}>
Your settings are private and wallet-bound. Sign once to load them the permission is cached for 4 minutes so you won&apos;t be prompted again while you browse.
<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'}>
@@ -442,6 +521,13 @@ export default function BotConfigPanel() {
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
@@ -461,6 +547,77 @@ export default function BotConfigPanel() {
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 }}>
@@ -481,7 +638,9 @@ export default function BotConfigPanel() {
? <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></>
: 'Generate at app.hyperliquid.xyz/API — paste the private key here. Never your MetaMask key.'}
: <>Don&apos;t have Hyperliquid?{' '}
<a href="https://app.hyperliquid.xyz" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--amber)', textDecoration: 'none' }}>Sign up free </a>
{' '}· Then go to API generate a trade-only key paste below.</>}
</div>
</div>
{/* Paper mode: show upgrade path instead of key input */}
@@ -526,10 +685,74 @@ export default function BotConfigPanel() {
))}
</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. Run one small test trade to confirm the live path end-to-end.</div>}
{keyState === 'ok' && <div style={{ fontSize: 11, color: 'var(--up)', marginTop: 6, paddingLeft: 50 }}>Saved. Enable Auto-Trade below, then try a paper trade first to confirm the live path works.</div>}
{!paperMode && !hlApiKeySet && (
<div style={{
marginTop: 10, paddingLeft: 50,
display: 'flex', alignItems: 'flex-start', gap: 6,
}}>
<span style={{ fontSize: 13, flexShrink: 0, marginTop: 1 }}></span>
<div style={{ fontSize: 11, color: 'var(--down)', lineHeight: 1.5 }}>
<strong>Do not paste your main wallet private key.</strong>{' '}
This field only accepts a Hyperliquid <em>trade-only API key</em> generate one at{' '}
<a href="https://app.hyperliquid.xyz/API" target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--down)', textDecorationColor: 'var(--down)' }}>
app.hyperliquid.xyz/API
</a>
{' '} &ldquo;Generate API wallet&rdquo;. A trade-only key cannot withdraw funds.
</div>
</div>
)}
{subState === 'err' && subErr && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8 }}>{subErr}</div>}
</div>
{/* ── Auto-Trade master switch ───────────────────────────────────────── */}
{isSubscribed && (hlApiKeySet || paperMode) && (
<div className="card" style={{ padding: '14px 20px', marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>Auto-Trade</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4 }}>
{autoTrade
? 'ON — bot opens positions automatically when qualifying Trump signals fire.'
: 'OFF — signals are shown but no trades are opened. Turn on when ready to go live.'}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
<button
onClick={() => flipAutoTrade(true)}
disabled={atState !== 'idle'}
style={{
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
border: 'none',
background: autoTrade ? 'var(--up)' : 'var(--bg-sunk)',
color: autoTrade ? '#fff' : 'var(--ink-4)',
opacity: atState !== 'idle' ? 0.5 : 1,
}}
>ON</button>
<button
onClick={() => flipAutoTrade(false)}
disabled={atState !== 'idle'}
style={{
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
border: '1px solid var(--line)',
background: !autoTrade ? 'var(--ink)' : 'transparent',
color: !autoTrade ? 'var(--bg)' : 'var(--ink-4)',
opacity: atState !== 'idle' ? 0.5 : 1,
}}
>OFF</button>
</div>
</div>
{atState !== 'idle' && (
<div style={{ fontSize: 11, marginTop: 8, color: atState === 'err' ? 'var(--down)' : 'var(--ink-4)' }}>
{atState === 'signing' ? 'Waiting for wallet signature…'
: atState === 'saving' ? 'Saving…'
: 'Failed to update — please try again.'}
</div>
)}
</div>
)}
{/* ── Trump Signal ───────────────────────────────────────────────────── */}
<div id="config-trump" className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 12 }}>
{/* Header */}
@@ -560,7 +783,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Per-trade size
<span className="hint">Notional in USD margin ${(settings.position_size_usd / settings.leverage).toFixed(2)} at {settings.leverage}×</span>
<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">
@@ -575,7 +798,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Leverage
<span className="hint">Event-driven scalp use lower leverage if unsure</span>
<span className="hint">How aggressive the position is. Start low (23×) and increase once you've seen the bot trade live.</span>
</div>
<div className="form-row-control">
<div className="slider-field">
@@ -584,13 +807,18 @@ export default function BotConfigPanel() {
<div className="ticks"><span>1×</span><span>25×</span><span>50×</span></div>
</div>
<span className="slider-readout">{settings.leverage}×</span>
{settings.leverage > 10 && (
<div className="settings-note warn" style={{ marginTop: 6 }}>
{settings.leverage}× is high for event-driven signals. A 1.5% stop-loss at {settings.leverage}× means the trade closes on a {(1.5 / settings.leverage).toFixed(1)}% price move against you. Consider 35× until you see how the bot performs live.
</div>
)}
</div>
</div>
<div className="form-row">
<div className="form-row-label">
Min AI confidence
<span className="hint">Skip signals below this score (0 = take all, 100 = only the highest-conviction)</span>
<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">
@@ -607,7 +835,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Take profit <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when unrealised gain hits this target. Required.</span>
<span className="hint">Bot locks in profit when the position gains this much. Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
@@ -623,7 +851,7 @@ export default function BotConfigPanel() {
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Stop loss <span style={{ color: 'var(--down)' }}>*</span>
<span className="hint">Auto-close when drawdown hits this limit. Required.</span>
<span className="hint">Bot cuts the loss when the position falls this much. Required.</span>
</div>
<div className="form-row-control">
<div className="num-field">
@@ -655,8 +883,8 @@ export default function BotConfigPanel() {
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 2 }}>
{macroOn
? 'You open a BTC long on Hyperliquid, then use /adopt in the Telegram bot to hand it to the bot for exit management.'
: 'Disabled — Macro Vibes alerts will not include bot management instructions.'}
? '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" />
@@ -719,7 +947,7 @@ export default function BotConfigPanel() {
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
BTC bottom leverage
<span className="hint">Separate from Trump leverage. The bot de-risks in stages before exchange liquidation.</span>
<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">
@@ -729,9 +957,9 @@ export default function BotConfigPanel() {
</div>
<span className="slider-readout">{lev}×</span>
<div className={`settings-note ${risky ? 'warn' : ''}`} style={{ marginTop: 6 }}>
At {lev}× it sheds near {(prot * 0.6).toFixed(0)}%, near {(prot * 0.8).toFixed(0)}%, fully out by {prot.toFixed(0)}%.
Exchange liquidation {liq.toFixed(0)}%.
{risky ? ' Above 2×, a normal correction can push you out early.' : ' Wide enough to survive a normal correction.'}
{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>
@@ -751,7 +979,7 @@ export default function BotConfigPanel() {
cursor: 'pointer', borderBottom: showAdvanced ? '1px solid var(--line)' : 'none',
}}
>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-2)' }}>Advanced</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--ink-2)' }}>Risk limits &amp; schedule</span>
<span style={{
fontSize: 11, color: 'var(--ink-4)',
display: 'flex', alignItems: 'center', gap: 10,
@@ -770,7 +998,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Daily trading cap
<span className="hint">Optional bot stops opening new trades once total notional crosses this limit in a UTC day.</span>
<span className="hint">Daily spending cap — bot stops opening new trades once it hits this amount.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap', gap: 10 }}>
<Switch on={useBudget} onChange={v => { setUseBudget(v); setDirty(true) }} />
@@ -792,7 +1020,7 @@ export default function BotConfigPanel() {
<div className="form-row">
<div className="form-row-label">
Trading schedule
<span className="hint">Bot only accepts signals inside this window. Times in your browser&apos;s local timezone. Leave off to trade anytime.</span>
<span className="hint">Daily recurring UTC window — bot only opens new trades within this time range. Overnight windows (e.g. 22:0002:00) are allowed. Leave off for 24/7.</span>
</div>
<div className="form-row-control" style={{ flexWrap: 'wrap' }}>
<Switch on={useSchedule} onChange={v => { setUseSchedule(v); setDirty(true) }} />
@@ -800,46 +1028,48 @@ export default function BotConfigPanel() {
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<div className="num-field">
<span className="prefix">From</span>
<input type="datetime-local" lang="en-US" value={fromLocal}
onChange={e => { setFromLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
<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="datetime-local" lang="en-US" value={untilLocal}
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 190 }} />
<input type="time" value={untilLocal}
onChange={e => { setUntilLocal(e.target.value); setDirty(true) }} style={{ width: 110 }} />
</div>
</div>
)}
</div>
</div>
{/* Manual window */}
<div className="form-row" style={{ marginBottom: 16 }}>
<div className="form-row-label">
Override window
<span className="hint">Open a timed override so the bot accepts signals for the next 124 hours useful for high-conviction catalysts like CPI or FOMC releases.</span>
{/* 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 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 override'}
</button>
</>
) : (
[1, 4, 24].map(h => (
<button key={h} className="btn ghost" style={{ padding: '6px 12px', fontSize: 12 }}
onClick={() => handleManualWindow(h)} disabled={mwBusy}>
{mwBusy && mwState === 'signing' ? 'Sign…' : `Override ${h}h`}
</button>
))
)}
{mwState === 'err' && <span style={{ fontSize: 11, color: 'var(--down)', width: '100%' }}>{mwErr}</span>}
</div>
</div>
)}
</div>
)}
+62 -22
View File
@@ -1,8 +1,8 @@
'use client'
import { useState, useMemo } from 'react'
import { useState, useMemo, useEffect } from 'react'
import { useLocale } from 'next-intl'
import type { BotTrade, TrumpPost } from '@/types'
import type { BotTrade } from '@/types'
import Pagination from '@/components/ui/Pagination'
// ── Formatters ────────────────────────────────────────────────────────────────
@@ -16,7 +16,8 @@ function fmtMoney(n: number, opts: { sign?: boolean; decimals?: number } = {}) {
return '$' + s
}
function fmtPct(n: number) { return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' }
function fmtHold(s: number) {
function fmtHold(s: number | null | undefined) {
if (s == null) return '—'
if (s < 60) return s + 's'
const m = Math.floor(s / 60)
if (m < 60) return m + 'm'
@@ -24,18 +25,36 @@ function fmtHold(s: number) {
}
interface Props {
trades: BotTrade[]
posts: TrumpPost[]
loading: boolean
trades: BotTrade[]
loading: boolean
locked?: boolean // true = waiting for wallet signature, not "no trades"
}
// ASSETS filter is derived dynamically from the trade set (see useMemo below)
// so new assets (TRUMP, BNB, DOGE, LINK, AAVE, ...) appear automatically.
const SIDES = ['all', 'long', 'short'] as const
// Human-readable labels for raw signal-source identifiers. Keeps the trade
// history readable for non-technical users (matches PostCards source labels).
const SOURCE_LABEL: Record<string, string> = {
truth: 'Trump',
btc_bottom_reversal: 'BTC Macro Bottom',
funding_reversal: 'Funding Reversal',
kol_divergence: 'KOL Divergence',
sma_reclaim: 'SMA Reclaim',
rsi_reversal: 'RSI Reversal',
breakout: 'Breakout',
adopted: 'Adopted',
manual: 'Manual',
unknown: 'Unknown',
}
function sourceLabel(src: string): string {
return SOURCE_LABEL[src?.toLowerCase()] ?? src
}
const TRADES_PER_PAGE = 25
export default function TradeTable({ trades, posts, loading }: Props) {
export default function TradeTable({ trades, loading, locked = false }: Props) {
const locale = useLocale()
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
const [assetFilter, setAssetFilter] = useState('all')
@@ -63,12 +82,31 @@ export default function TradeTable({ trades, posts, loading }: Props) {
return Array.from(set).sort()
}, [trades])
// Reset filters that no longer apply to the loaded trade set. On a wallet
// switch the trades prop changes but the local filter state persists, so a
// source/asset/side the previous wallet had could stay "stuck" — and when
// the new wallet has a single source the breakdown card (sources.length > 1)
// disappears, removing the only Clear affordance. Clamping here guarantees
// the user always sees their full new history.
useEffect(() => {
if (sourceFilter !== 'all' && !sources.includes(sourceFilter)) setSourceFilter('all')
if (assetFilter !== 'all' && !assets.includes(assetFilter)) setAssetFilter('all')
}, [sources, assets, sourceFilter, assetFilter])
// Per-source PnL aggregate — the critical view for "which module makes money".
// Computed BEFORE the asset/side filter so the source breakdown reflects the
// full universe, not whatever sub-filter is currently applied.
// Apply asset/side/paper filters for the source breakdown so the cards stay
// consistent with the KPI and table rows below. We intentionally do NOT apply
// sourceFilter here — filtering by source would trivially make one card 100%.
const filteredForSources = useMemo(() => trades.filter(t => {
if (assetFilter !== 'all' && t.asset !== assetFilter) return false
if (sideFilter !== 'all' && t.side !== sideFilter) return false
if (hidePaper && t.is_paper) return false
return true
}), [trades, assetFilter, sideFilter, hidePaper])
const perSource = useMemo(() => {
const acc: Record<string, { trades: number; pnl: number; wins: number; paper: number }> = {}
for (const t of trades) {
for (const t of filteredForSources) {
const k = t.trigger_source || 'unknown'
if (!acc[k]) acc[k] = { trades: 0, pnl: 0, wins: 0, paper: 0 }
acc[k].trades += 1
@@ -79,7 +117,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
if (t.is_paper) acc[k].paper += 1
}
return acc
}, [trades])
}, [filteredForSources])
const filtered = useMemo(() => trades.filter(t => {
if (sourceFilter !== 'all' && (t.trigger_source || 'unknown') !== sourceFilter) return false
@@ -98,8 +136,9 @@ export default function TradeTable({ trades, posts, loading }: Props) {
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)
const withHold = filtered.filter(t => t.hold_seconds !== null)
const avgHold = withHold.length > 0
? Math.round(withHold.reduce((s, t) => s + (t.hold_seconds ?? 0), 0) / withHold.length)
: 0
return (
@@ -111,7 +150,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
fontSize: 11, fontWeight: 600, letterSpacing: '0.06em',
textTransform: 'uppercase', color: 'var(--ink-3)', marginBottom: 10,
}}>
{isZh ? '按信号来源拆分盈亏' : 'P&L by signal source'}
{isZh ? '按信号来源拆分盈亏' : 'Which signal makes money'}
</div>
<div style={{
display: 'grid',
@@ -142,7 +181,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
fontSize: 11, fontWeight: 600, color: 'var(--ink-2)',
marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{src}
{sourceLabel(src)}
{s.paper > 0 && (
<span style={{
fontSize: 9, marginLeft: 6, padding: '1px 5px',
@@ -171,7 +210,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
border: '1px solid var(--line)', borderRadius: 4,
background: 'transparent', cursor: 'pointer', color: 'var(--ink-2)' }}
>
{isZh ? `清除(当前为 “${sourceFilter}”)` : `Clear (showing “${sourceFilter}”)`}
{isZh ? `清除(当前为 “${sourceLabel(sourceFilter)}”)` : `Clear (showing “${sourceLabel(sourceFilter)}”)`}
</button>
)}
</div>
@@ -255,7 +294,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
<th>{isZh ? '开仓' : 'Entry'}</th>
<th>{isZh ? '平仓' : 'Exit'}</th>
<th>{isZh ? '持仓' : 'Hold'}</th>
<th>{isZh ? '触发内容' : 'Trigger'}</th>
<th>{isZh ? '触发信号' : 'What triggered it'}</th>
<th>P&amp;L</th>
</tr>
</thead>
@@ -263,12 +302,13 @@ export default function TradeTable({ trades, posts, loading }: Props) {
{filtered.length === 0 && (
<tr>
<td colSpan={8} style={{ textAlign: 'center', padding: 40, color: 'var(--ink-3)' }}>
{isZh ? '没有符合条件的交易。' : 'No trades found'}
{locked
? (isZh ? '签名解锁后即可查看交易历史。' : 'Sign to unlock your trade history above.')
: (isZh ? '没有符合条件的交易。' : 'No trades found')}
</td>
</tr>
)}
{pageRows.map(t => {
const tp = posts.find(p => p.id === t.trigger_post_id)
const roi =
t.entry_price && t.exit_price != null
? ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1)
@@ -283,7 +323,7 @@ export default function TradeTable({ trades, posts, loading }: Props) {
padding: '2px 7px', borderRadius: 4,
background: 'var(--bg-sunk)',
}}>
{src}
{sourceLabel(src)}
</span>
{t.is_paper && (
<span style={{
@@ -310,9 +350,9 @@ export default function TradeTable({ trades, posts, loading }: Props) {
<td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : '—'}</td>
<td className="mono" style={{ color: 'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth: 260 }}>
{tp ? (
{t.trigger_post_text ? (
<span style={{ fontSize: 12, color: 'var(--ink-2)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{tp.text.slice(0, 60)}
{t.trigger_post_text.slice(0, 60)}
</span>
) : (
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}></span>