Production polish: i18n shelved, PWA icons, SEO/GEO cleanup, UX fixes
Big-picture changes since 01be8e7:
New routes — /trump /btc /kol /trades /analytics /archive plus four
SEO landing pages (/methodology /glossary /case-studies /contact),
all served under /[locale]. Each has dedicated metadata + JSON-LD.
KOL page (new) — DigestWidget + OnchainWidget + TalkVsTradesWidget,
filter by handle/ticker, click-through to per-post detail with the
original AI-extracted ticker/conviction/quote.
BTC page (new) — tabbed: macro-bottom (AHR999 + 200WMA + Pi Cycle)
and funding-rate reversal, with live sparkline + threshold bands.
Telegram card on Settings — wallet-link code generation, status,
disconnect. Preferences moved into the bot itself (/trump /btc etc.)
so the card stays minimal.
SignalMonitor (new) — ETH/LINK Bollinger breakout monitor in its own
component, shares the singleton WsProvider so no second WS opens.
WS singleton refactor (lib/wsContext) — shared WsProvider + useWsSubscribe
hook. Cleanup now actively closes the socket on unmount; previously the
local `ws` couldn't be reached from cleanup and leaked one connection
per StrictMode remount.
OpenPositions polling no longer pops MetaMask in the background —
splits into load('first') for user-initiated and load('poll') that
uses getCachedViewEnvelope without signing.
i18n shelved — proxy.ts (Next 16 middleware rename) wires next-intl
but only Navbar + layout footer have translations. Rest of UI used
isZh ternary scattered across 28 files. All `const isZh = locale === 'zh'`
flipped to `const isZh = false` so every site renders English; Chinese
branches kept as dead code so revival is one regex away.
LanguageSwitch hidden but file kept. zh-CN hreflang removed from
metadata + sitemap to avoid duplicate-content penalties.
Wallet error handling — lib/walletError.ts: isUserRejection walks EIP-1193
code 4001 + .cause chain; previous string-match for "reject"/"denied"
broke for users running MetaMask in non-English UIs. 12 call sites
migrated across 5 components.
PWA icons — app/icon.tsx + app/apple-icon.tsx render the brand "α" via
Next's ImageResponse so no static PNG asset is required. manifest.ts
references /icon and /apple-icon dynamic routes.
OG image + sitemap + robots — dynamic 1200×630 OG card; robots blocks
both /en/settings and /zh/settings; sitemap only emits /en routes;
JSON-LD covers SoftwareApplication + Organization + 10-Q FAQPage.
Landing page polish — Launch Dashboard button stripped of magnetic
hover + shimmer + lift + glow expansion (multiple users found it busy);
hero scramble alphabet swapped to alphanumeric (was block characters);
"15 KOL feeds" copy updated to 19 in 5 places.
PostCards source icons — new entries for btc_bottom_reversal,
funding_reversal, kol_divergence so they no longer fall through to
the generic "first letter" fallback.
Archive page filter — excludes funding_reversal + kol_divergence
(previously only excluded truth + btc_bottom_reversal so new live
signals leaked into the legacy archive).
Cache + skeleton loading — lib/cache.ts SWR module with per-key TTL,
applied across the major pages so navigation feels instant.
SignConfirmSheet — 4 hardcoded Chinese strings translated to English
(English users were seeing zh-only "需要钱包签名" etc).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+22
-785
@@ -1,791 +1,28 @@
|
||||
'use client'
|
||||
import type { Metadata } from 'next'
|
||||
import { getLocale } from 'next-intl/server'
|
||||
import TradesPageClient from './TradesPageClient'
|
||||
|
||||
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'
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://trumpsignal.com'
|
||||
|
||||
// ─── 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'
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const locale = await getLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const title = isZh ? '交易执行与持仓' : 'Trades & Open Positions'
|
||||
const description = isZh
|
||||
? '查看当前持仓、历史成交、按信号来源拆分的盈亏,以及机器人是否已经具备真实执行条件。'
|
||||
: 'Review open positions, historical executions, source-level P&L, and whether the bot is ready for live trading.'
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/${locale}/trades`,
|
||||
languages: {
|
||||
en: `${siteUrl}/en/trades`,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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<UserSettings>(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<UserSettings>) {
|
||||
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 <div className="card" style={{ padding: 32, marginBottom: 28, minHeight: 120 }} />
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 32, textAlign: 'center', marginBottom: 28 }}>
|
||||
<p style={{ color: 'var(--ink-3)', marginBottom: 16 }}>Connect your wallet to configure the bot and view your trades.</p>
|
||||
<button className="btn amber" onClick={handleConnectWallet}>
|
||||
Connect wallet
|
||||
</button>
|
||||
</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' ? '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 = (
|
||||
<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 ? <>Linked · <span className="mono">{hlApiKeyMasked ?? '···'}</span> · trade-only scope</>
|
||||
: '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>
|
||||
)
|
||||
|
||||
// ── 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 (
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<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}
|
||||
|
||||
<div className="card" 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 }}>Executes on Hyperliquid when a Trump post clears your filters.</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 ? (
|
||||
<button className="btn amber" style={{ padding: '8px 18px', fontSize: 13 }}
|
||||
onClick={handleSubscribe} disabled={subState === 'signing' || subState === 'saving'}>
|
||||
{subState === 'signing' ? 'Sign…' : subState === 'saving' ? 'Activating…' : 'Subscribe'}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<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 (persists until fully configured) ─── */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{!isSubscribed ? (
|
||||
<div style={{ padding: 28, color: 'var(--ink-3)', fontSize: 13, lineHeight: 1.65 }}>
|
||||
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' && <div style={{ marginTop: 10, color: 'var(--down)' }}>{subErr}</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">
|
||||
<span className="section-head-label">Execution</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div className="form-row-control">
|
||||
<div className="num-field">
|
||||
<span className="prefix">$</span>
|
||||
<input type="number" min={5} max={10000} step={5} value={settings.position_size_usd}
|
||||
onChange={e => updateSettings({ position_size_usd: Math.max(5, +e.target.value || 0) })} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-4)' }}>$5 – $10,000</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Leverage
|
||||
<span className="hint">Applied to every position</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>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Min AI confidence
|
||||
<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" style={{ marginTop: 20 }}>
|
||||
<span className="section-head-label">Limits & risk</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Daily budget <span style={{ color: 'var(--down)' }}>*</span>
|
||||
<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>
|
||||
<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>
|
||||
<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 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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Trades table ─────────────────────────────────────────────────────────────
|
||||
export default function TradesPage() {
|
||||
const [trades, setTrades] = useState<BotTrade[]>([])
|
||||
const [posts, setPosts] = useState<TrumpPost[]>([])
|
||||
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 (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Trades</h1>
|
||||
<p className="page-sub">Bot configuration · execution history · P&L</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bot config: settings + HL key */}
|
||||
<BotConfigPanel />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="kpi-row" style={{ gridTemplateColumns: 'repeat(4,1fr)', marginBottom: 20 }}>
|
||||
<div className="kpi"><div className="label">Total trades</div><div className="value">{filtered.length}</div></div>
|
||||
<div className="kpi"><div className="label">Win rate</div><div className="value">{priced.length ? ((wins/priced.length)*100).toFixed(1)+'%' : '—'}</div><div className="foot"><span>{wins}W · {losses}L</span></div></div>
|
||||
<div className="kpi accent"><div className="label">Net P&L</div><div className="value">{fmtMoney(totalPnl,{sign:true,decimals:0})}</div></div>
|
||||
<div className="kpi"><div className="label">Avg hold</div><div className="value">{avgHold ? fmtHold(avgHold) : '—'}</div></div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="row gap-s" style={{ marginBottom: 14 }}>
|
||||
<div className="nav-tabs">
|
||||
{(['all','BTC','ETH'] as const).map(a => (
|
||||
<button key={a} className={`nav-tab ${assetFilter===a?'active':''}`} onClick={() => setAssetFilter(a)}>
|
||||
{a==='all'?'All assets':a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="nav-tabs">
|
||||
{(['all','long','short'] as const).map(s => (
|
||||
<button key={s} className={`nav-tab ${sideFilter===s?'active':''}`} onClick={() => setSideFilter(s)}>
|
||||
{s.charAt(0).toUpperCase()+s.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ textAlign:'center', padding:60, color:'var(--ink-3)' }}>Loading…</div>}
|
||||
|
||||
{!loading && (
|
||||
<div className="card flush" style={{ overflow:'hidden' }}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset</th><th>Side</th><th>Entry</th><th>Exit</th>
|
||||
<th>Hold</th><th>Trigger post</th><th>P&L</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 && (
|
||||
<tr><td colSpan={7} style={{ textAlign:'center', padding:40, color:'var(--ink-3)' }}>No trades found</td></tr>
|
||||
)}
|
||||
{filtered.map(t => {
|
||||
const tp = posts.find(p => p.id === t.trigger_post_id)
|
||||
// Guard: entry_price may be 0 on a corrupt row; exit_price may
|
||||
// be null for externally-closed trades. Both → NaN otherwise.
|
||||
const roi =
|
||||
t.entry_price && t.exit_price != null
|
||||
? ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1)
|
||||
: 0
|
||||
return (
|
||||
<tr key={t.id}>
|
||||
<td>
|
||||
<div className="row gap-s">
|
||||
<span className={`asset-dot ${t.asset.toLowerCase()}`} />
|
||||
<span style={{ fontWeight:500 }}>{t.asset}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td><span className={`side-pill ${t.side}`}>{t.side==='long'?'↗ LONG':'↘ SHORT'}</span></td>
|
||||
<td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : '—'}</td>
|
||||
<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 ? (
|
||||
<span style={{ fontSize:12, color:'var(--ink-2)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap', display:'block' }}>
|
||||
{tp.text.slice(0,60)}…
|
||||
</span>
|
||||
) : <span style={{ fontSize:12, color:'var(--ink-4)' }}>—</span>}
|
||||
</td>
|
||||
<td>
|
||||
<div className="stack" style={{ alignItems:'flex-end' }}>
|
||||
{t.pnl_usd === null || t.pnl_usd === undefined ? (
|
||||
<span style={{ fontSize:12, color:'var(--ink-4)' }} title="Closed externally on Hyperliquid — PnL not recorded">
|
||||
n/a
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={`delta ${t.pnl_usd>=0?'up':'down'}`} style={{ fontWeight:600, fontSize:14 }}>
|
||||
{fmtMoney(t.pnl_usd,{sign:true})}
|
||||
</span>
|
||||
<span className={`delta ${roi>=0?'up':'down'}`} style={{ fontSize:11, opacity:0.7 }}>{fmtPct(roi)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return <TradesPageClient />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user