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:
@@ -457,7 +457,7 @@ export default function MacroPanel() {
|
||||
label="Altcoin Season"
|
||||
value={ind.altcoin_season_index == null ? '—' : ind.altcoin_season_index.toFixed(0) + ' / 100'}
|
||||
tone={toneAltseason(ind.altcoin_season_index)}
|
||||
hint="% of top-50 alts that beat BTC over the last 30 days. < 25 = Bitcoin season, 25–60 = mixed, 60–75 = alt strength building, ≥ 75 = altseason."
|
||||
hint="% of top-50 alts that beat BTC over the last 90 days (blockchaincenter.net formula). < 25 = Bitcoin season, 25–60 = mixed, 60–75 = alt strength building, ≥ 75 = altseason."
|
||||
summary={altSay}
|
||||
activeIndex={altActive}
|
||||
thresholds={[
|
||||
@@ -466,8 +466,8 @@ export default function MacroPanel() {
|
||||
{ label: '60–75 alt strength', tone: 'up' },
|
||||
{ label: '≥ 75 altseason', tone: 'up' },
|
||||
]}
|
||||
chartHref="https://www.coinglass.com/en/pro/i/alt-coin-season"
|
||||
chartLabel="CoinGlass"
|
||||
chartHref="https://www.blockchaincenter.net/altcoin-season-index/"
|
||||
chartLabel="BlockchainCenter"
|
||||
/>
|
||||
<MetricCard
|
||||
rank={3}
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAccount, useConnect, useSignMessage } from 'wagmi'
|
||||
import type { BotPerformance } from '@/types'
|
||||
import { useDashboardStore } from '@/store/dashboard'
|
||||
import { getUserPublic, setHlApiKey, subscribe } from '@/lib/api'
|
||||
import { signRequest } from '@/lib/signedRequest'
|
||||
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
|
||||
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
|
||||
|
||||
// Action names must match backend/app/api/{user,subscribe}.py
|
||||
const ACTION_SET_API_KEY = 'set_hl_api_key'
|
||||
const ACTION_SUBSCRIBE = 'subscribe'
|
||||
|
||||
interface Props {
|
||||
performance?: BotPerformance | null
|
||||
}
|
||||
|
||||
type SaveState = 'idle' | 'signing' | 'saving' | 'success' | 'error'
|
||||
|
||||
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 default function BotPanel({ performance }: Props) {
|
||||
const { isSubscribed, hlApiKeySet, hlApiKeyMasked, botReadiness, setBotReadiness, setHlApiKeySet, setSubscribed } = useDashboardStore()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { connectAsync, connectors } = useConnect()
|
||||
const { signMessageAsync } = useSignMessage()
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [saveState, setSaveState] = useState<SaveState>('idle')
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
const [subState, setSubState] = useState<'idle' | 'signing' | 'saving' | 'error'>('idle')
|
||||
const [subError, setSubError] = useState('')
|
||||
const [connectError, setConnectError] = useState('')
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
setSubscribed(false)
|
||||
setHlApiKeySet(false)
|
||||
setBotReadiness('unknown')
|
||||
return
|
||||
}
|
||||
setSubscribed(false)
|
||||
setHlApiKeySet(false)
|
||||
setBotReadiness('unknown')
|
||||
getUserPublic(address.toLowerCase())
|
||||
.then((user) => {
|
||||
setSubscribed(user.active)
|
||||
setHlApiKeySet(user.hl_api_key_set)
|
||||
setBotReadiness(user.hl_api_key_set ? 'saved' : 'unknown')
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [address, isConnected, setHlApiKeySet, setSubscribed, setBotReadiness])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
async function handleSubscribe() {
|
||||
if (!address) return
|
||||
setSubError('')
|
||||
try {
|
||||
setSubState('signing')
|
||||
const env = await signRequest({
|
||||
action: ACTION_SUBSCRIBE,
|
||||
wallet: address,
|
||||
body: null,
|
||||
signMessageAsync,
|
||||
})
|
||||
setSubState('saving')
|
||||
await subscribe(env)
|
||||
await refreshUserState(address)
|
||||
setSubState('idle')
|
||||
} catch (err: unknown) {
|
||||
setSubError(walletErrorLabel(err, 'Signature cancelled', 120))
|
||||
setSubState('error')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveKey() {
|
||||
if (!address || !apiKey.trim()) return
|
||||
if (!apiKey.trim().startsWith('0x') || apiKey.trim().length !== 66) {
|
||||
setErrorMsg('Key must start with 0x and be 66 characters')
|
||||
setSaveState('error')
|
||||
return
|
||||
}
|
||||
setErrorMsg('')
|
||||
try {
|
||||
setSaveState('signing')
|
||||
const trimmed = apiKey.trim()
|
||||
const env = await signRequest({
|
||||
action: ACTION_SET_API_KEY,
|
||||
wallet: address,
|
||||
body: { api_key: trimmed },
|
||||
signMessageAsync,
|
||||
})
|
||||
setSaveState('saving')
|
||||
const res = await setHlApiKey(env, trimmed)
|
||||
const pub = await refreshUserState(address)
|
||||
setHlApiKeySet(pub.hl_api_key_set, res.masked_key)
|
||||
setApiKey('')
|
||||
setSaveState('success')
|
||||
} catch (err: unknown) {
|
||||
if (isUserRejection(err)) {
|
||||
setErrorMsg('Signature cancelled')
|
||||
} else {
|
||||
setErrorMsg(walletErrorLabel(err, 'Signature cancelled', 120))
|
||||
}
|
||||
setSaveState('error')
|
||||
}
|
||||
}
|
||||
|
||||
const saveLabel =
|
||||
saveState === 'signing' ? 'Waiting for signature…'
|
||||
: saveState === 'saving' ? 'Saving…'
|
||||
: saveState === 'success' ? '✓ Saved'
|
||||
: 'Save key'
|
||||
|
||||
async function handleConnectWallet() {
|
||||
setConnectError('')
|
||||
try {
|
||||
const connector = await getFirstReadyConnector(connectors)
|
||||
if (!connector) {
|
||||
setConnectError('No wallet connector is available right now.')
|
||||
return
|
||||
}
|
||||
await connectAsync({ connector })
|
||||
} catch (err: unknown) {
|
||||
setConnectError(walletConnectErrorLabel(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bot status card — dark design */}
|
||||
<div className="bot-status">
|
||||
<div className="bot-head">
|
||||
<h3>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: botReadiness === 'ready' ? 'var(--amber)' : hlApiKeySet ? 'var(--up)' : isSubscribed ? 'var(--up)' : 'oklch(70% 0.01 85)', display: 'inline-block' }} />
|
||||
Auto-trader
|
||||
</h3>
|
||||
<span style={{ fontSize: 11, opacity: 0.7, textTransform: 'uppercase', letterSpacing: '0.08em' }}>30 days</span>
|
||||
</div>
|
||||
|
||||
<div className="bot-stats">
|
||||
<div className="bot-stat">
|
||||
<div className="k">Net P&L</div>
|
||||
<div className="v amber">
|
||||
{performance ? (performance.net_pnl_usd >= 0 ? '+$' : '-$') + Math.abs(performance.net_pnl_usd).toLocaleString('en-US', { maximumFractionDigits: 0 }) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-stat">
|
||||
<div className="k">Win rate</div>
|
||||
<div className="v up">
|
||||
{performance ? (performance.win_rate * 100).toFixed(1) + '%' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bot-stat">
|
||||
<div className="k">Trades</div>
|
||||
<div className="v">{performance?.total_trades ?? '—'}</div>
|
||||
</div>
|
||||
<div className="bot-stat">
|
||||
<div className="k">Avg hold</div>
|
||||
<div className="v">{performance ? fmtHold(performance.avg_hold_seconds) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bot-cta">
|
||||
{(!mounted || !isConnected) && (
|
||||
<>
|
||||
<button className="btn amber" style={{ width: '100%' }}
|
||||
onClick={() => { void handleConnectWallet() }}>
|
||||
Connect wallet
|
||||
</button>
|
||||
{connectError && (
|
||||
<p style={{ fontSize: 11, color: 'var(--down)', marginTop: 6, textAlign: 'center' }}>{connectError}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{mounted && isConnected && !isSubscribed && (
|
||||
<div style={{ width: '100%' }}>
|
||||
<button
|
||||
className="btn amber"
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleSubscribe}
|
||||
disabled={subState === 'signing' || subState === 'saving'}
|
||||
>
|
||||
{subState === 'signing' ? 'Waiting for signature…' : subState === 'saving' ? 'Activating…' : 'Start trading'}
|
||||
</button>
|
||||
{subState === 'error' && subError && (
|
||||
<p style={{ fontSize: 11, color: 'var(--down)', marginTop: 6, textAlign: 'center' }}>{subError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{mounted && isConnected && isSubscribed && !hlApiKeySet && (
|
||||
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'oklch(75% 0.01 85)', padding: '6px 0' }}>
|
||||
↓ Paste your Hyperliquid API key below to finish setup
|
||||
</div>
|
||||
)}
|
||||
{mounted && isConnected && isSubscribed && hlApiKeySet && (
|
||||
<div style={{ width: '100%', textAlign: 'center', fontSize: 12, color: 'var(--amber)', padding: '6px 0', fontWeight: 500 }}>
|
||||
✓ Setup saved · verification still depends on backend
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HL API Key card — only when subscribed */}
|
||||
{mounted && isConnected && isSubscribed && (
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<div className="section-title">
|
||||
<h2 style={{ fontSize: 14 }}>Hyperliquid API key</h2>
|
||||
{hlApiKeySet && <span style={{ fontSize: 11, color: 'var(--up)', fontWeight: 500 }}>✓ Connected</span>}
|
||||
</div>
|
||||
|
||||
{hlApiKeySet && !apiKey && (
|
||||
<div className="row between" style={{ padding: '10px 12px', background: 'var(--bg-sunk)', borderRadius: 'var(--r-sm)', border: '1px solid var(--line)', marginBottom: 12 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--up)', fontFamily: 'var(--mono)' }}>
|
||||
{hlApiKeyMasked ?? '···'}
|
||||
</span>
|
||||
<button
|
||||
style={{ fontSize: 11, color: 'var(--ink-3)' }}
|
||||
onClick={() => { setSaveState('idle'); setApiKey(' ') }}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!hlApiKeySet || apiKey) && (
|
||||
<>
|
||||
<div className="field" style={{ marginBottom: 12 }}>
|
||||
<label>API wallet private key</label>
|
||||
<input
|
||||
value={apiKey.trim() === '' && hlApiKeySet ? '' : apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value)
|
||||
if (saveState === 'error' || saveState === 'success') setSaveState('idle')
|
||||
}}
|
||||
placeholder="0x…"
|
||||
/>
|
||||
<div className="hint">
|
||||
From{' '}
|
||||
<a href="https://app.hyperliquid.xyz/API" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--amber-ink)' }}>
|
||||
app.hyperliquid.xyz/API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveState === 'error' && errorMsg && (
|
||||
<p style={{ fontSize: 11, color: 'var(--down)', marginBottom: 8 }}>{errorMsg}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className={`btn ${saveState === 'success' ? 'ghost' : 'amber'}`}
|
||||
style={{ width: '100%' }}
|
||||
onClick={handleSaveKey}
|
||||
disabled={saveState === 'signing' || saveState === 'saving' || !apiKey.trim()}
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hlApiKeySet && (
|
||||
<details style={{ marginTop: 12 }}>
|
||||
<summary style={{ fontSize: 11, color: 'var(--ink-3)', cursor: 'pointer' }}>
|
||||
How to get your API key
|
||||
</summary>
|
||||
<ol style={{ marginTop: 8, fontSize: 11, color: 'var(--ink-3)', paddingLeft: 16, lineHeight: 1.7 }}>
|
||||
<li>Deposit USDC at app.hyperliquid.xyz</li>
|
||||
<li>Go to app.hyperliquid.xyz/API</li>
|
||||
<li>Click <strong>Generate API Wallet</strong></li>
|
||||
<li>Sign with MetaMask (no gas)</li>
|
||||
<li>Copy the private key → paste above</li>
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -417,7 +417,7 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
}
|
||||
}, [candles, posts, externalSelectedId, asset])
|
||||
|
||||
useEffect(() => { fittedRef.current = false }, [timeframe])
|
||||
useEffect(() => { fittedRef.current = false }, [timeframe, asset])
|
||||
|
||||
// Live-tick the rightmost candle so the chart feels alive between REST polls.
|
||||
// lightweight-charts' `series.update()` either appends a new bar (newer time)
|
||||
|
||||
@@ -66,16 +66,18 @@ function LocalDateTime({ iso, opts }: { iso: string; opts?: Intl.DateTimeFormatO
|
||||
// When adding a new scanner source, register it here too — otherwise it
|
||||
// falls through to the generic "first letter" fallback which has no title
|
||||
// or accent colour.
|
||||
const SOURCE_DISPLAY: Record<string, { glyph: string; cls: string; title: string }> = {
|
||||
truth: { glyph: 'T', cls: 'truth', title: 'Trump · Truth Social' },
|
||||
breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' },
|
||||
vcp_breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner' },
|
||||
reversal: { glyph: '⇋', cls: 'reversal', title: 'Reversal scanner' },
|
||||
btc_bottom_reversal: { glyph: '₿', cls: 'reversal', title: 'BTC · Macro Bottom Reversal' },
|
||||
funding_reversal: { glyph: 'ƒ', cls: 'reversal', title: 'BTC · Funding Rate Reversal' },
|
||||
kol_divergence: { glyph: '⚖', cls: 'whale', title: 'KOL · Talks vs Trades Divergence' },
|
||||
whale: { glyph: '🐋', cls: 'whale', title: 'On-chain whale alert' },
|
||||
manual: { glyph: '✋', cls: 'manual', title: 'Manual entry' },
|
||||
export const SOURCE_DISPLAY: Record<string, { glyph: string; cls: string; title: string; label: string }> = {
|
||||
truth: { glyph: 'T', cls: 'truth', title: 'Trump · Truth Social', label: '@realDonaldTrump' },
|
||||
breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner', label: 'Breakout scanner' },
|
||||
vcp_breakout: { glyph: '▲', cls: 'breakout', title: 'VCP / breakout scanner', label: 'Breakout scanner' },
|
||||
reversal: { glyph: '⇋', cls: 'reversal', title: 'Reversal scanner', label: 'Reversal scanner' },
|
||||
btc_bottom_reversal: { glyph: '₿', cls: 'reversal', title: 'BTC Macro Bottom scanner', label: 'BTC Macro Bottom' },
|
||||
funding_reversal: { glyph: 'ƒ', cls: 'reversal', title: 'BTC Funding Rate Reversal scanner', label: 'Funding Reversal' },
|
||||
kol_divergence: { glyph: '⚖', cls: 'whale', title: 'KOL Talks-vs-Trades Divergence', label: 'KOL Divergence' },
|
||||
sma_reclaim: { glyph: '〽', cls: 'breakout', title: 'SMA reclaim scanner', label: 'SMA Reclaim' },
|
||||
rsi_reversal: { glyph: '⇋', cls: 'reversal', title: 'RSI reversal scanner', label: 'RSI Reversal' },
|
||||
whale: { glyph: '🐋', cls: 'whale', title: 'On-chain whale alert', label: 'Whale alert' },
|
||||
manual: { glyph: '✋', cls: 'manual', title: 'Manual entry', label: 'Manual' },
|
||||
}
|
||||
|
||||
function SourceIcon({ source }: { source: string }) {
|
||||
@@ -119,7 +121,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`post-row ${selected ? 'selected' : ''} ${aiScored ? '' : 'noise'}`}
|
||||
className={`post-row ${selected ? 'selected' : ''} ${aiScored ? '' : 'noise'} ${post.signal === 'buy' ? 'signal-buy' : post.signal === 'short' ? 'signal-short' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* ── main row ── */}
|
||||
@@ -130,7 +132,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
|
||||
{/* Author label depends on source — non-Trump signals come from
|
||||
technical scanners or external modules, not @realDonaldTrump. */}
|
||||
<span className="mono" style={{ color: 'var(--ink-2)', fontWeight: 500 }}>
|
||||
{post.source === 'truth' ? '@realDonaldTrump' : post.source}
|
||||
{SOURCE_DISPLAY[post.source?.toLowerCase?.()]?.label ?? post.source ?? 'Signal'}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<TimeAgo iso={post.published_at} suffix=" ago" />
|
||||
@@ -186,7 +188,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
|
||||
<div className="impact-mini">
|
||||
{impact ? (
|
||||
<>
|
||||
<span className="tf">1h peak</span>
|
||||
<span className="tf">1h move</span>
|
||||
<span className={`delta ${impact.m1h == null ? '' : impact.m1h >= 0 ? 'up' : 'down'}`}>
|
||||
{impact.m1h == null ? '…' : fmtPct(impact.m1h)}
|
||||
</span>
|
||||
@@ -246,7 +248,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
|
||||
</span>
|
||||
{post.expected_move_pct != null && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ink-3)' }}>
|
||||
{`AI expects ~${post.expected_move_pct}% in 1h`}
|
||||
{`model projection ~${post.expected_move_pct}% · 1h`}
|
||||
</span>
|
||||
)}
|
||||
{post.category && (
|
||||
@@ -260,7 +262,9 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
|
||||
{/* AI reasoning */}
|
||||
{post.ai_reasoning && (
|
||||
<div>
|
||||
<div className="ai-reasoning-label">AI reasoning</div>
|
||||
<div className="ai-reasoning-label">
|
||||
{post.signal === 'buy' || post.signal === 'short' ? 'Why this signal fired' : 'Why this was filtered out'}
|
||||
</div>
|
||||
<div className="ai-reasoning-card">
|
||||
{post.ai_reasoning}
|
||||
</div>
|
||||
@@ -270,7 +274,7 @@ const PostRow = memo(function PostRow({ post, selected, onClick }: PostRowProps)
|
||||
{/* Price impact — peak move in signal direction per window */}
|
||||
{impact && (
|
||||
<div>
|
||||
<div className="tiny" style={{ marginBottom: 8 }}>{`Peak move · ${impact.asset}`}</div>
|
||||
<div className="tiny" style={{ marginBottom: 8 }}>{`Price moved · ${impact.asset} · after signal`}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{(['m5', 'm15', 'm1h'] as const).map(key => {
|
||||
const label = key === 'm5' ? '5m' : key === 'm15' ? '15m' : '1h'
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useWsSubscribe } from '@/lib/wsContext'
|
||||
|
||||
const API_BASE = '/api/proxy/api'
|
||||
@@ -30,82 +29,22 @@ function timeAgo(iso: string) {
|
||||
return `${Math.round(diff / 3600)}h ago`
|
||||
}
|
||||
|
||||
// ── iOS-style toggle switch ───────────────────────────────────────────────────
|
||||
function ToggleSwitch({ on, loading, onToggle }: {
|
||||
on: boolean
|
||||
loading: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
disabled={loading}
|
||||
title={on ? 'Click to disable' : 'Click to enable'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
padding: 0,
|
||||
opacity: loading ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{/* Switch track */}
|
||||
<div style={{
|
||||
width: 44,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
background: on ? '#22c55e' : 'var(--ink-4)',
|
||||
position: 'relative',
|
||||
transition: 'background 0.2s',
|
||||
flexShrink: 0,
|
||||
opacity: on ? 1 : 0.5,
|
||||
}}>
|
||||
{/* Thumb */}
|
||||
<div style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
background: '#fff',
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
left: on ? 20 : 2,
|
||||
transition: 'left 0.2s',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
}} />
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: on ? '#22c55e' : 'var(--ink-3)',
|
||||
letterSpacing: '0.05em',
|
||||
minWidth: 24,
|
||||
}}>
|
||||
{on ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
// Display-only: toggle is operator-only (requires X-Ingest-Key) so the
|
||||
// on/off switch is intentionally absent from the user-facing UI.
|
||||
export default function SignalMonitor() {
|
||||
const locale = useLocale()
|
||||
const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json
|
||||
const [enabled, setEnabledState] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [backendOk, setBackendOk] = useState<boolean | null>(null)
|
||||
const [signals, setSignals] = useState<SignalAlert[]>([])
|
||||
const [btcTrend, setBtcTrend] = useState<string | null>(null)
|
||||
const [lastScan, setLastScan] = useState<Date | null>(null)
|
||||
const [signals, setSignals] = useState<SignalAlert[]>([])
|
||||
const [btcTrend, setBtcTrend] = useState<string | null>(null)
|
||||
const [lastScan, setLastScan] = useState<Date | null>(null)
|
||||
|
||||
// ── Load initial state ───────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/signal/status`)
|
||||
.then(r => { if (!r.ok) throw new Error(); return r.json() })
|
||||
.then(d => { setEnabledState(d.enabled); setBackendOk(true) })
|
||||
.catch(() => setBackendOk(false))
|
||||
.then(d => { setEnabledState(d.enabled) })
|
||||
.catch(() => {})
|
||||
|
||||
fetch(`${API_BASE}/signal/history?limit=20`)
|
||||
.then(r => r.json())
|
||||
@@ -128,22 +67,6 @@ export default function SignalMonitor() {
|
||||
setSignals(prev => [alert, ...prev].slice(0, 50))
|
||||
})
|
||||
|
||||
// ── Toggle ───────────────────────────────────────────────────────────────
|
||||
const toggle = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const next = !enabled
|
||||
const r = await fetch(`${API_BASE}/signal/toggle?enabled=${next}`, { method: 'POST' })
|
||||
if (!r.ok) throw new Error()
|
||||
const d = await r.json()
|
||||
setEnabledState(d.enabled)
|
||||
setBackendOk(true)
|
||||
} catch {
|
||||
setBackendOk(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [enabled])
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────────────────
|
||||
const btcUp = btcTrend?.includes('↑')
|
||||
|
||||
@@ -160,20 +83,16 @@ export default function SignalMonitor() {
|
||||
{isZh ? 'ETH · LINK · 5 分钟扫描' : 'ETH · LINK · 5m scan'}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch on={enabled} loading={loading} onToggle={toggle} />
|
||||
</div>
|
||||
|
||||
{/* Backend offline warning */}
|
||||
{backendOk === false && (
|
||||
<div style={{
|
||||
fontSize: 11, color: '#f59e0b',
|
||||
padding: '6px 10px', borderRadius: 6,
|
||||
background: 'rgba(245,158,11,0.1)',
|
||||
marginBottom: 12,
|
||||
}}>
|
||||
⚠️ {isZh ? '后端离线,开关暂时不可用' : "Backend offline — toggle won't work"}
|
||||
{/* Enabled status dot — display-only; toggle is operator-only (X-Ingest-Key) */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: enabled ? '#22c55e' : 'var(--ink-3)' }}>
|
||||
<div style={{
|
||||
width: 7, height: 7, borderRadius: '50%',
|
||||
background: enabled ? '#22c55e' : 'var(--ink-4)',
|
||||
boxShadow: enabled ? '0 0 0 2px rgba(34,197,94,0.25)' : 'none',
|
||||
}} />
|
||||
{enabled ? (isZh ? '监控中' : 'Active') : (isZh ? '已暂停' : 'Paused')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status row: BTC trend + last scan */}
|
||||
<div style={{
|
||||
@@ -208,7 +127,7 @@ export default function SignalMonitor() {
|
||||
}}>
|
||||
{enabled
|
||||
? (isZh ? '· 正在等待信号…' : '· Watching for signals…')
|
||||
: (isZh ? '· 打开开关后开始监控' : '· Enable the toggle to start watching')}
|
||||
: (isZh ? '· 暂无历史信号' : '· No signals yet')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { usePathname } from 'next/navigation'
|
||||
import { useAccount, useConnect, useDisconnect } from 'wagmi'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { getFirstReadyConnector, walletConnectErrorLabel } from '@/lib/walletConnect'
|
||||
import { needsMobileWallet } from '@/lib/mobileWallet'
|
||||
import MobileWalletSheet from '@/components/wallet/MobileWalletSheet'
|
||||
// i18n shelved — LanguageSwitch hidden but kept on disk for future revival.
|
||||
// import LanguageSwitch from './LanguageSwitch'
|
||||
|
||||
@@ -31,7 +33,12 @@ function ThemeToggle() {
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="icon-btn theme-toggle" onClick={toggle}>
|
||||
<button
|
||||
className="icon-btn theme-toggle"
|
||||
onClick={toggle}
|
||||
aria-label={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" strokeWidth="2" />
|
||||
@@ -56,6 +63,7 @@ export default function Navbar() {
|
||||
const [walletMenuOpen, setWalletMenuOpen] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [connectError, setConnectError] = useState('')
|
||||
const [mobileSheetOpen, setMobileSheetOpen] = useState(false)
|
||||
|
||||
async function copyAddress(addr: string) {
|
||||
let ok = false
|
||||
@@ -89,10 +97,20 @@ export default function Navbar() {
|
||||
|
||||
async function handleConnectWallet() {
|
||||
setConnectError('')
|
||||
|
||||
// On mobile without an injected provider, show the wallet deep-link sheet
|
||||
// instead of throwing "No wallet found". This lets users open the dApp
|
||||
// inside MetaMask / Trust / Coinbase without a confusing error.
|
||||
if (needsMobileWallet()) {
|
||||
setMobileSheetOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const connector = await getFirstReadyConnector(connectors)
|
||||
if (!connector) {
|
||||
setConnectError('No wallet connector is available right now.')
|
||||
// Desktop with no extension — give a helpful hint instead of raw error.
|
||||
setConnectError('No wallet extension found. Install MetaMask or another browser wallet.')
|
||||
return
|
||||
}
|
||||
await connectAsync({ connector })
|
||||
@@ -124,6 +142,8 @@ export default function Navbar() {
|
||||
const shortAddr = address ? `${address.slice(0, 6)}…${address.slice(-4)}` : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileWalletSheet open={mobileSheetOpen} onClose={() => setMobileSheetOpen(false)} />
|
||||
<nav className="nav">
|
||||
<Link href={`/${locale}`} className="brand" style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||
<BrandMark />
|
||||
@@ -204,5 +224,6 @@ export default function Navbar() {
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Ticker — horizontal scrolling price tape under the navbar, the way a crypto
|
||||
* terminal shows a running market strip. Driven by the shared WebSocket price
|
||||
* feed (no new socket). Each asset cell flashes green/red on a price change and
|
||||
* shows the live last price.
|
||||
*
|
||||
* Behaviour notes:
|
||||
* - The strip is duplicated once and translated -50% in a CSS marquee so the
|
||||
* loop is seamless (`ticker-marquee` keyframe in globals.css).
|
||||
* - prefers-reduced-motion disables the scroll (CSS), so the tape becomes a
|
||||
* static, readable row instead of moving.
|
||||
* - The animation is paused on hover so a user can read a specific price.
|
||||
* - Assets with no tick yet render "—" rather than a stale zero.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useWsSubscribe } from '@/lib/wsContext'
|
||||
|
||||
// Assets the backend streams (see binance ASSET_MAP + hl_price_feed). Order is
|
||||
// the display order in the tape.
|
||||
const ASSETS = ['BTC', 'ETH', 'SOL', 'BNB', 'DOGE', 'LINK', 'AAVE', 'TRUMP', 'HYPE'] as const
|
||||
|
||||
interface Cell {
|
||||
price: number | null
|
||||
dir: 'up' | 'down' | null
|
||||
}
|
||||
|
||||
function fmtPrice(p: number): string {
|
||||
if (p >= 1000) return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 0 })
|
||||
if (p >= 1) return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 2 })
|
||||
return '$' + p.toLocaleString('en-US', { maximumFractionDigits: 4 })
|
||||
}
|
||||
|
||||
export default function Ticker() {
|
||||
const [cells, setCells] = useState<Record<string, Cell>>({})
|
||||
// Per-asset flash-clear timers so a fast stream doesn't leak setTimeouts.
|
||||
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
|
||||
useWsSubscribe('price', (msg) => {
|
||||
const m = msg as { asset?: string; price?: number }
|
||||
if (!m.asset || typeof m.price !== 'number') return
|
||||
const asset = m.asset.toUpperCase()
|
||||
if (!ASSETS.includes(asset as (typeof ASSETS)[number])) return
|
||||
|
||||
setCells((prev) => {
|
||||
const old = prev[asset]
|
||||
const dir: Cell['dir'] =
|
||||
old?.price != null && m.price !== old.price
|
||||
? (m.price! > old.price ? 'up' : 'down')
|
||||
: old?.dir ?? null
|
||||
return { ...prev, [asset]: { price: m.price!, dir } }
|
||||
})
|
||||
|
||||
// Clear the flash after 600ms.
|
||||
clearTimeout(timers.current[asset])
|
||||
timers.current[asset] = setTimeout(() => {
|
||||
setCells((prev) => (prev[asset] ? { ...prev, [asset]: { ...prev[asset], dir: null } } : prev))
|
||||
}, 600)
|
||||
})
|
||||
|
||||
const strip = ASSETS.map((a) => {
|
||||
const c = cells[a]
|
||||
return (
|
||||
<span key={a} className={`ticker-cell ${c?.dir ? `tick-flash-${c.dir}` : ''}`}>
|
||||
<span className="ticker-sym">{a}</span>
|
||||
<span className="ticker-px">{c?.price != null ? fmtPrice(c.price) : '—'}</span>
|
||||
<span className={`ticker-arrow ${c?.dir ?? ''}`} aria-hidden>
|
||||
{c?.dir === 'up' ? '▲' : c?.dir === 'down' ? '▼' : ''}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="ticker" role="region" aria-label="Live market prices">
|
||||
<div className="ticker-track">
|
||||
{strip}
|
||||
{/* duplicate for seamless loop */}
|
||||
<span aria-hidden style={{ display: 'contents' }}>{strip}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import {
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
type OpenPosition,
|
||||
type TodayStats,
|
||||
} from '@/lib/api'
|
||||
import { getCachedViewEnvelope, signRequest } from '@/lib/signedRequest'
|
||||
import { getCachedViewEnvelope, getOrCreateViewEnvelope, signRequest, type SignedEnvelope } from '@/lib/signedRequest'
|
||||
import { isUserRejection, walletErrorLabel } from '@/lib/walletError'
|
||||
|
||||
const POLL_MS = 15_000
|
||||
@@ -85,7 +86,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
</span>
|
||||
{/* Entry → Current */}
|
||||
<div className="mono" style={{ fontSize: 12 }}>
|
||||
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry → mark</div>
|
||||
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry / current price</div>
|
||||
<div>
|
||||
{fmtMoney(p.entry_price)}
|
||||
<span style={{ color: 'var(--ink-4)' }}> → </span>
|
||||
@@ -102,10 +103,10 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
{((p.derisk_steps ?? 0) > 0 || (p.addon_steps ?? 0) > 0) && (
|
||||
<div style={{ fontSize: 9, marginTop: 2 }}>
|
||||
{(p.addon_steps ?? 0) > 0 && (
|
||||
<span style={{ color: 'var(--up)' }}>{`⬆ pyramided ×${p.addon_steps} `}</span>
|
||||
<span style={{ color: 'var(--up)' }}>{`↑ scaled in ×${p.addon_steps} `}</span>
|
||||
)}
|
||||
{(p.derisk_steps ?? 0) > 0 && (
|
||||
<span style={{ color: 'var(--down)' }}>{`⬇ de-risked ×${p.derisk_steps}`}</span>
|
||||
<span style={{ color: 'var(--down)' }}>{`↓ trimmed ×${p.derisk_steps}`}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -114,8 +115,8 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
onClick={() => onToggleGrow(p)}
|
||||
disabled={growBusy}
|
||||
title={p.grow_mode
|
||||
? (isZh ? 'Grow 已开启:趋势确认后会继续顺势加仓。点击关闭。' : 'Grow ON — scales INTO this winner on confirmed trend. Click to turn off.')
|
||||
: (isZh ? 'Grow 已关闭:仅持有并做保护性降风险。点击后允许顺势加仓。' : 'Grow OFF — hold + protective de-risk only. Click to let it pyramid.')}
|
||||
? (isZh ? '加仓已开启:趋势确认后自动追加仓位。点击关闭。' : 'Scale-in ON — bot adds to this position when trend is confirmed. Click to turn off.')
|
||||
: (isZh ? '加仓已关闭:仅持有并做保护性减仓。' : 'Scale-in OFF — hold + protective trim only. Click to allow adding.')}
|
||||
style={{
|
||||
marginTop: 4, fontSize: 9, fontWeight: 700, padding: '2px 7px',
|
||||
borderRadius: 999, cursor: growBusy ? 'wait' : 'pointer',
|
||||
@@ -124,7 +125,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
color: p.grow_mode ? '#fff' : 'var(--ink-3)',
|
||||
}}
|
||||
>
|
||||
{p.grow_mode ? '⬆ Grow ON' : 'Grow OFF'}
|
||||
{p.grow_mode ? '↑ Scale-in ON' : 'Scale-in OFF'}
|
||||
</button>
|
||||
</div>
|
||||
{/* Hold time */}
|
||||
@@ -141,7 +142,7 @@ function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: {
|
||||
</div>
|
||||
{p.realized_usd != null && p.realized_usd !== 0 && (
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||||
locked in {fmtMoney(p.realized_usd, { sign: true })}
|
||||
banked {fmtMoney(p.realized_usd, { sign: true })} from partial close
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -172,6 +173,7 @@ export default function OpenPositions() {
|
||||
const [today, setToday] = useState<TodayStats | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [needsUnlock, setNeedsUnlock] = useState(false)
|
||||
const [unlocking, setUnlocking] = useState(false)
|
||||
// Close-confirmation modal state
|
||||
const [closing, setClosing] = useState<OpenPosition | null>(null)
|
||||
const [closeState, setCloseState] = useState<'idle'|'signing'|'closing'|'err'>('idle')
|
||||
@@ -212,10 +214,11 @@ export default function OpenPositions() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected || !address) {
|
||||
setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null)
|
||||
return
|
||||
}
|
||||
// B39: wipe stale data from the previous wallet immediately — don't wait
|
||||
// for the async fetch to complete before the UI shows a clean slate.
|
||||
setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null)
|
||||
|
||||
if (!isConnected || !address) return
|
||||
let cancelled = false
|
||||
|
||||
// Only reuse a cached envelope here. Open positions should never trigger
|
||||
@@ -253,6 +256,31 @@ export default function OpenPositions() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [address, isConnected])
|
||||
|
||||
// In-page unlock: mint a view_user envelope (one signature) right here and
|
||||
// load positions immediately — no detour to the Settings page. view_user is
|
||||
// a superset accepted by /positions/open and /positions/today.
|
||||
async function handleUnlock() {
|
||||
if (!address || unlocking) return
|
||||
setUnlocking(true); setErr(null)
|
||||
try {
|
||||
const env = await getOrCreateViewEnvelope({
|
||||
action: 'view_user', wallet: address, signMessageAsync,
|
||||
})
|
||||
const [p, t] = await Promise.all([
|
||||
getOpenPositions(address, env),
|
||||
getTodayStats(address, env),
|
||||
])
|
||||
setPositions(p.positions); setToday(t); setNeedsUnlock(false); setErr(null)
|
||||
} catch (e: unknown) {
|
||||
// User-cancelled signature is benign — keep the unlock CTA visible.
|
||||
if (!isUserRejection(e)) {
|
||||
setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '解锁失败' : 'unlock failed'))
|
||||
}
|
||||
} finally {
|
||||
setUnlocking(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger close. Two-step: opens modal, user confirms, we sign + POST.
|
||||
async function confirmCloseTrade() {
|
||||
if (!address || !closing) return
|
||||
@@ -293,15 +321,39 @@ export default function OpenPositions() {
|
||||
Open positions
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--ink)' }}>
|
||||
Sign in once to view open positions
|
||||
Sign in to see open positions
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}>
|
||||
Go to the <strong>Settings page</strong> and click <em>Sign in to view your settings</em>. After signing, your open positions will appear here automatically.
|
||||
Sign once on this device to unlock your positions (valid for a few
|
||||
minutes, no transaction, no gas).
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10, flexWrap: 'wrap' }}>
|
||||
<button className="btn amber" style={{ fontSize: 13, padding: '8px 16px' }}
|
||||
disabled={unlocking} onClick={handleUnlock}>
|
||||
{unlocking ? 'Waiting for signature…' : 'Unlock positions'}
|
||||
</button>
|
||||
<Link href={`/${locale}/settings`} style={{ fontSize: 12, color: 'var(--ink-3)', textDecoration: 'none' }}>
|
||||
or go to Settings →
|
||||
</Link>
|
||||
</div>
|
||||
{err && (
|
||||
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}>⚠️ {err}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (positions === null && today === null) return null
|
||||
// When the first load fails, positions and today are still null but err is set.
|
||||
// Without this guard the component returns null (blank), swallowing the error.
|
||||
if (positions === null && today === null) {
|
||||
if (err) {
|
||||
return (
|
||||
<div className="card" style={{ padding: '12px 16px', marginBottom: 16, fontSize: 12, color: 'var(--down)' }}>
|
||||
⚠️ Could not load positions — {err}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const totalUnrealized = (positions ?? []).reduce(
|
||||
(s, p) => s + (p.unrealized_usd ?? 0), 0,
|
||||
@@ -363,7 +415,7 @@ export default function OpenPositions() {
|
||||
</div>
|
||||
{today != null && (today.open_realized_usd ?? 0) !== 0 && (
|
||||
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
|
||||
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} locked in on open trades`}
|
||||
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} banked from partial closes`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -447,8 +499,8 @@ export default function OpenPositions() {
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 16 }}>
|
||||
{closing.is_paper
|
||||
? (isZh ? '这是模拟交易,平仓只做本地记录,不会调用 Hyperliquid。' : 'Paper trade — synthetic close, no Hyperliquid call.')
|
||||
: (isZh ? '会向 Hyperliquid 发送 IOC 市价单。已实现盈亏将立即确认。' : 'Sends an IOC market order to Hyperliquid. Realised PnL is permanent.')}
|
||||
? (isZh ? '这是模拟交易,平仓只做本地记录,不会动用真实资金。' : 'Paper trade — simulated close, no real funds involved.')
|
||||
: (isZh ? '立即按市价在 Hyperliquid 上平仓。盈亏将立即结算,无法撤销。' : 'Closes at market price on Hyperliquid right now. The profit or loss becomes final and cannot be undone.')}
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'var(--bg-sunk)', borderRadius: 8, padding: 14, marginBottom: 16,
|
||||
@@ -458,17 +510,17 @@ export default function OpenPositions() {
|
||||
<span className="mono">{fmtMoney(closing.entry_price)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
|
||||
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Mark'}</span>
|
||||
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Current price'}</span>
|
||||
<span className="mono">{closing.current_price != null ? fmtMoney(closing.current_price) : (isZh ? '暂无' : 'n/a')}</span>
|
||||
</div>
|
||||
{closing.realized_usd != null && closing.realized_usd !== 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6, color: 'var(--ink-3)' }}>
|
||||
<span>{isZh ? '已锁定(降风险)' : 'Locked in (de-risked)'}</span>
|
||||
<span>{isZh ? '已落袋(之前减仓)' : 'Already banked'}</span>
|
||||
<span className="mono">{fmtMoney(closing.realized_usd, { sign: true })}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, fontWeight: 600, marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--line)' }}>
|
||||
<span>{isZh ? '当前未平部分预估盈亏' : 'Est. PnL on open portion'}</span>
|
||||
<span>{isZh ? '平仓后将结算' : 'You\'ll get if you close now'}</span>
|
||||
<span style={{
|
||||
color: (closing.unrealized_usd ?? 0) > 0 ? 'var(--up)'
|
||||
: (closing.unrealized_usd ?? 0) < 0 ? 'var(--down)' : 'var(--ink-2)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import { getUserPublic, setAutoTrade, type UserPublic } from '@/lib/api'
|
||||
@@ -25,8 +25,8 @@ import { confirmSign } from '@/components/wallet/SignConfirmSheet'
|
||||
*/
|
||||
|
||||
const SYS = {
|
||||
trump: { idx: '①', name: 'Trump', accent: '#b45309', soft: 'rgba(180,83,9,0.08)' },
|
||||
btc: { idx: '②', name: 'Macro Vibes', accent: '#16a34a', soft: 'rgba(22,163,74,0.08)' },
|
||||
trump: { idx: '', name: 'Trump Signal', accent: '#b45309', soft: 'rgba(180,83,9,0.08)' },
|
||||
btc: { idx: '', name: 'Macro Vibes', accent: '#16a34a', soft: 'rgba(22,163,74,0.08)' },
|
||||
} as const
|
||||
|
||||
const ROW: React.CSSProperties = {
|
||||
@@ -49,9 +49,18 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
// Generation counter: each address change increments it so any in-flight
|
||||
// refresh from the previous address can detect it's stale.
|
||||
// `snap !== address` inside a useCallback is a stale-closure trap — both
|
||||
// refer to the same closed-over value so the check is always false.
|
||||
const genRef = useRef(0)
|
||||
useEffect(() => { genRef.current++ }, [address])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!address) { setPub(null); return }
|
||||
const gen = genRef.current
|
||||
const p = await getUserPublic(address.toLowerCase()).catch(() => null)
|
||||
if (gen !== genRef.current) return // wallet changed while in-flight
|
||||
setPub(p)
|
||||
}, [address])
|
||||
|
||||
@@ -71,6 +80,14 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
if (busy) return
|
||||
if (!address) { setErr(isZh ? '请先连接右上角的钱包。' : 'Connect your wallet first (top-right).'); return }
|
||||
if (!subscribed) { setErr(isZh ? '钱包尚未订阅,请先去设置页完成订阅。' : 'Wallet not subscribed — subscribe on the Settings page first.'); return }
|
||||
// B51: live users without an HL API key cannot actually execute trades —
|
||||
// block the toggle so the ON state never silently becomes a no-op.
|
||||
if (on && !paper && !pub?.hl_api_key_set) {
|
||||
setErr(isZh
|
||||
? '未绑定 Hyperliquid API Key。请先在设置页保存 API Key,Auto-Trade 才能真实下单。'
|
||||
: 'No Hyperliquid API key saved. Add your HL API key on the Settings page before enabling Auto-Trade.')
|
||||
return
|
||||
}
|
||||
if (autoOn === on) return
|
||||
|
||||
// Claim the busy slot BEFORE awaiting confirmSign — otherwise a rapid
|
||||
@@ -141,38 +158,34 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
// auto-open and (b) be a confusing second copy of the SAME global switch
|
||||
// shown on the Trump page. Show the adopt-only flow instead.
|
||||
if (system === 'btc') {
|
||||
const linkStyle: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 12px',
|
||||
borderRadius: 999, border: '1px solid var(--line)', background: 'var(--surface)',
|
||||
fontSize: 12, color: 'var(--ink)', textDecoration: 'none', fontWeight: 700,
|
||||
boxShadow: 'var(--shadow-1)',
|
||||
}
|
||||
// Compact inline strip — signal fires → you open → bot manages.
|
||||
// No big card; the data panel is the main content. Settings link stays
|
||||
// for users who want to configure, but the explanation is one line.
|
||||
return (
|
||||
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '14px 16px', background: s.soft,
|
||||
borderLeft: `4px solid ${s.accent}` }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
|
||||
{s.idx} {s.name} — manage-only
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '16px', fontSize: 13, color: 'var(--ink-2)', lineHeight: 1.6 }}>
|
||||
<p style={{ margin: '0 0 10px' }}>
|
||||
<strong>Macro Vibes does not auto-open trades.</strong> When a bottom-reversal
|
||||
signal fires you get a Telegram alert. You open the position yourself on
|
||||
Hyperliquid, then hand it to the bot with <code>/adopt</code> — the bot then
|
||||
manages the exit (staged stop ladder, de-risk, pyramid, peak-trail).
|
||||
</p>
|
||||
<p style={{ margin: '0 0 12px', color: 'var(--ink-4)', fontSize: 12 }}>
|
||||
The Auto-Trade switch on the Trump page controls Trump (System 1)
|
||||
auto-opens only — it has no effect on Macro Vibes.
|
||||
</p>
|
||||
<Link href={settingsHref} style={linkStyle}>
|
||||
<span style={{ fontSize: 10, letterSpacing: '0.08em', textTransform: 'uppercase',
|
||||
color: 'var(--ink-4)' }}>Settings</span>
|
||||
<span>{settingsLabel}</span>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
gap: 12, flexWrap: 'wrap',
|
||||
padding: '9px 14px', marginBottom: 14,
|
||||
borderRadius: 8,
|
||||
background: s.soft,
|
||||
borderLeft: `3px solid ${s.accent}`,
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.4 }}>
|
||||
<strong style={{ color: s.accent }}>You open · bot manages exit</strong>
|
||||
<span style={{ color: 'var(--ink-4)', marginLeft: 8 }}>
|
||||
Signal → Telegram → open on Hyperliquid → <code style={{ fontSize: 11 }}>/adopt</code>
|
||||
</span>
|
||||
</span>
|
||||
<Link
|
||||
href={settingsHref}
|
||||
style={{
|
||||
fontSize: 11, color: s.accent, textDecoration: 'none',
|
||||
fontWeight: 700, flexShrink: 0,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{settingsLabel} settings ↗
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -248,11 +261,18 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
netMsg = isZh
|
||||
? 'Auto-Trade 当前为关闭: Trump 信号仍会显示,但不会自动开仓。'
|
||||
: 'Auto-Trade is OFF — Trump signals are shown in the feed but NOT traded.'
|
||||
} else if (pub?.trump_enabled === false) {
|
||||
// Auto-Trade is ON but the Trump (System-1) gate is OFF, so the backend
|
||||
// skips every Trump signal (bot_engine: trump_enabled OFF → not traded).
|
||||
// Without this branch the UI falsely promised "next signal auto-opens".
|
||||
netMsg = isZh
|
||||
? 'Auto-Trade 已开启,但 Trump 系统①未启用: 在设置页打开 Trump 开关后才会自动开仓。'
|
||||
: 'Auto-Trade is ON, but the Trump system is disabled — enable Trump in Settings before signals will trade.'
|
||||
} else {
|
||||
netOk = true
|
||||
netMsg = isZh
|
||||
? `Auto-Trade 已开启(Trump 系统①): 下一条合格 Trump 信号会自动开出${paper ? '模拟' : '真实'}仓位。`
|
||||
: `Auto-Trade ON (Trump / System 1) — will open a ${paper ? 'PAPER' : 'LIVE'} trade on the next qualifying Trump signal.`
|
||||
: `Auto-Trade ON — will open a ${paper ? 'PAPER' : 'LIVE'} trade on the next qualifying Trump signal.`
|
||||
}
|
||||
|
||||
const Pill = ({ active, onClick, children, tone }: {
|
||||
@@ -278,7 +298,7 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
<div style={{ padding: '14px 16px', background: s.soft,
|
||||
borderLeft: `4px solid ${s.accent}` }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: s.accent }}>
|
||||
{s.idx} {s.name} {isZh ? '控制面板' : '— control'}
|
||||
{s.name} {isZh ? '控制面板' : '— Auto-Trade'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -288,25 +308,10 @@ export default function SystemControl({ system }: { system: 'trump' | 'btc' }) {
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700 }}>
|
||||
Auto-Trade <span style={{ fontSize: 10, fontWeight: 600,
|
||||
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? '· Trump 系统①' : '· TRUMP (System 1)'}</span>
|
||||
color: 'var(--ink-4)', marginLeft: 6 }}>{isZh ? '· 事件交易' : '· event-driven'}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3, maxWidth: 480, lineHeight: 1.5 }}>
|
||||
{isZh ? (
|
||||
<>
|
||||
<strong>控制 Trump 信号是否自动开仓。</strong> 关闭时:信号仍会扫描并显示,但不会自动交易。
|
||||
开启时:满足条件的 Trump 信号会自动开仓。无论开关状态如何,
|
||||
已开仓位的止损和分级降风险都会继续保护持仓。
|
||||
(Macro Vibes 不自动开仓——见 Macro 页的 manage-only 说明。)
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>Controls Trump (System 1) auto-opens.</strong> OFF: signals
|
||||
scanned & shown in the feed, nothing traded. ON: a qualifying
|
||||
Trump signal auto-opens a trade. Stop-loss / staged de-risk always
|
||||
protect open positions either way. (Macro Vibes is manage-only and
|
||||
never auto-opens — see its page.)
|
||||
</>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 3 }}>
|
||||
{isZh ? 'OFF = 只监控信号 · ON = 自动开仓' : 'OFF = monitor only · ON = auto-open on qualifying Trump signals'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useAccount, useSignMessage } from 'wagmi'
|
||||
import {
|
||||
@@ -36,19 +36,36 @@ export default function TelegramCard() {
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
// Generation counter guards against stale-closure trap: `snap !== address`
|
||||
// inside useCallback compares two closed-over copies of the same value and
|
||||
// is always false. genRef is a mutable ref readable from any closure.
|
||||
const genRef = useRef(0)
|
||||
useEffect(() => { genRef.current++ }, [address])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!address) {
|
||||
setLoading(false)
|
||||
setStatus(null)
|
||||
return
|
||||
}
|
||||
if (!address) { setLoading(false); setStatus(null); return }
|
||||
const gen = genRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const s = await getTelegramStatus(address.toLowerCase())
|
||||
const { getCachedViewEnvelope } = await import('@/lib/signedRequest')
|
||||
const cached = getCachedViewEnvelope('view_user', address)
|
||||
const s = await getTelegramStatus(address.toLowerCase(), cached ?? undefined)
|
||||
if (gen !== genRef.current) return // wallet changed while in-flight
|
||||
setStatus(s); setErr('')
|
||||
} catch (e) {
|
||||
if (gen !== genRef.current) return
|
||||
setErr(e instanceof Error ? e.message : 'load failed')
|
||||
} finally { setLoading(false) }
|
||||
} finally {
|
||||
if (gen === genRef.current) setLoading(false)
|
||||
}
|
||||
}, [address])
|
||||
|
||||
// B32: clear stale status immediately when wallet changes so the previous
|
||||
// wallet's binding info never flickers in before the new fetch resolves.
|
||||
useEffect(() => {
|
||||
setStatus(null)
|
||||
setCode(null)
|
||||
setErr('')
|
||||
}, [address])
|
||||
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
@@ -112,7 +129,7 @@ export default function TelegramCard() {
|
||||
Telegram alerts
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.6 }}>
|
||||
Connect your wallet first. After that, you can link Telegram for alert delivery and Pro wallet-bound notifications.
|
||||
You can get free Telegram alerts without a wallet — just open the bot. Connect a wallet here to also get alerts tailored to your own positions.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -180,8 +197,8 @@ export default function TelegramCard() {
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginTop: 2 }}>
|
||||
{status.bound
|
||||
? (isZh ? '打开机器人调整偏好(/trump /btc /funding /kol /conf /quiet)' : 'Open the bot to adjust preferences (/trump /btc /funding /kol /conf /quiet)')
|
||||
: (isZh ? '打开机器人并发送 /start,无需额外账号' : 'Open the bot and send /start — no account needed')}
|
||||
? (isZh ? '在机器人里可开关各类提醒、设置免打扰时段' : 'Open the bot to choose which alerts you get and set quiet hours')
|
||||
: (isZh ? '打开机器人并发送 /start,无需额外账号' : 'Open the bot and tap Start — no account needed')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,11 +214,11 @@ export default function TelegramCard() {
|
||||
</span>
|
||||
{status.wallet_address ? (
|
||||
<>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>·</span>
|
||||
<span style={{ color: 'var(--up)', fontSize: 11 }}>Pro</span>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>·</span>
|
||||
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>·</span>
|
||||
<button className="btn ghost" disabled={busy} onClick={handleUnbind}
|
||||
style={{ fontSize: 11, color: 'var(--down)', padding: '2px 8px' }}>
|
||||
{isZh ? '断开钱包绑定' : 'Disconnect wallet'}
|
||||
@@ -209,7 +226,7 @@ export default function TelegramCard() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: 'var(--ink-5)' }}>·</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>·</span>
|
||||
<span>{isZh ? `已发送 ${status.total_alerts_sent ?? 0} 条提醒` : `${status.total_alerts_sent ?? 0} alerts sent`}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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:00–02:00) are VALID — the backend's
|
||||
// _is_in_active_window treats af_t > au_t as a wrap-around window
|
||||
// (now >= from OR now <= until). Only reject when start === end, which
|
||||
// would describe a zero-length (or full-day-ambiguous) window.
|
||||
if (new Date(schedUntil).getTime() === new Date(schedFrom).getTime()) { setSaveErr('Start and end can’t be the same time'); setSaveState('err'); return }
|
||||
}
|
||||
const tp = settings.take_profit_pct, sl = settings.stop_loss_pct, bd = settings.daily_budget_usd
|
||||
if (trumpOn) {
|
||||
@@ -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'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'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>
|
||||
{' '}→ “Generate API wallet”. A trade-only key cannot withdraw funds.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subState === 'err' && subErr && <div style={{ fontSize: 12, color: 'var(--down)', marginTop: 8 }}>{subErr}</div>}
|
||||
</div>
|
||||
|
||||
{/* ── Auto-Trade master switch ───────────────────────────────────────── */}
|
||||
{isSubscribed && (hlApiKeySet || paperMode) && (
|
||||
<div className="card" style={{ padding: '14px 20px', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 2 }}>Auto-Trade</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink-4)', lineHeight: 1.4 }}>
|
||||
{autoTrade
|
||||
? 'ON — bot opens positions automatically when qualifying Trump signals fire.'
|
||||
: 'OFF — signals are shown but no trades are opened. Turn on when ready to go live.'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={() => flipAutoTrade(true)}
|
||||
disabled={atState !== 'idle'}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: autoTrade ? 'var(--up)' : 'var(--bg-sunk)',
|
||||
color: autoTrade ? '#fff' : 'var(--ink-4)',
|
||||
opacity: atState !== 'idle' ? 0.5 : 1,
|
||||
}}
|
||||
>ON</button>
|
||||
<button
|
||||
onClick={() => flipAutoTrade(false)}
|
||||
disabled={atState !== 'idle'}
|
||||
style={{
|
||||
padding: '6px 14px', borderRadius: 6, fontSize: 12, fontWeight: 700, cursor: 'pointer',
|
||||
border: '1px solid var(--line)',
|
||||
background: !autoTrade ? 'var(--ink)' : 'transparent',
|
||||
color: !autoTrade ? 'var(--bg)' : 'var(--ink-4)',
|
||||
opacity: atState !== 'idle' ? 0.5 : 1,
|
||||
}}
|
||||
>OFF</button>
|
||||
</div>
|
||||
</div>
|
||||
{atState !== 'idle' && (
|
||||
<div style={{ fontSize: 11, marginTop: 8, color: atState === 'err' ? 'var(--down)' : 'var(--ink-4)' }}>
|
||||
{atState === 'signing' ? 'Waiting for wallet signature…'
|
||||
: atState === 'saving' ? 'Saving…'
|
||||
: 'Failed to update — please try again.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Trump Signal ───────────────────────────────────────────────────── */}
|
||||
<div id="config-trump" className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 12 }}>
|
||||
{/* Header */}
|
||||
@@ -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 (2–3×) 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 3–5× until you see how the bot performs live.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-row-label">
|
||||
Min AI confidence
|
||||
<span className="hint">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 & 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'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:00–02:00) are allowed. Leave off for 24/7.</span>
|
||||
</div>
|
||||
<div className="form-row-control" style={{ flexWrap: 'wrap' }}>
|
||||
<Switch on={useSchedule} onChange={v => { setUseSchedule(v); setDirty(true) }} />
|
||||
@@ -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 1–24 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>
|
||||
)}
|
||||
|
||||
@@ -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&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>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnimatedNumber — flashes green/red when its value changes, the way a live
|
||||
* trading terminal ticks. Pure presentational: it renders `display` (already
|
||||
* formatted) but watches `value` (the raw number) to decide flash direction.
|
||||
*
|
||||
* The flash is a CSS class toggled for ~600ms via a keyframe (`tick-flash-up` /
|
||||
* `tick-flash-down` in globals.css). Respects prefers-reduced-motion (the CSS
|
||||
* keyframes are disabled there, so this degrades to a plain number).
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
/** Raw numeric value — drives the flash direction when it changes. */
|
||||
value: number | null | undefined
|
||||
/** Pre-formatted string to actually show (e.g. "$72,140"). */
|
||||
display: string
|
||||
/** Extra className(s) for the wrapper. */
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export default function AnimatedNumber({ value, display, className = '', style }: Props) {
|
||||
const prev = useRef<number | null | undefined>(value)
|
||||
const [dir, setDir] = useState<'up' | 'down' | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const p = prev.current
|
||||
if (value != null && p != null && value !== p) {
|
||||
setDir(value > p ? 'up' : 'down')
|
||||
const id = setTimeout(() => setDir(null), 600)
|
||||
prev.current = value
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
prev.current = value
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`tick-num ${dir ? `tick-flash-${dir}` : ''} ${className}`}
|
||||
style={style}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ function PageBtn({ num, active, onClick }: { num: number; active: boolean; onCli
|
||||
const [hover, setHover] = React.useState(false)
|
||||
|
||||
const bg = active
|
||||
? 'var(--brand, #f5a524)'
|
||||
? 'var(--amber)'
|
||||
: hover
|
||||
? 'var(--bg-sunk)'
|
||||
: 'transparent'
|
||||
@@ -105,7 +105,7 @@ function PageBtn({ num, active, onClick }: { num: number; active: boolean; onCli
|
||||
: 'var(--ink-2)'
|
||||
|
||||
const border = active
|
||||
? '1.5px solid var(--brand, #f5a524)'
|
||||
? '1.5px solid var(--amber)'
|
||||
: '1.5px solid var(--line)'
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { useWsSubscribe } from '@/lib/wsContext'
|
||||
|
||||
interface TradeAlert {
|
||||
id: number
|
||||
event: 'execution_failed' | 'insufficient_balance' | 'budget_reached'
|
||||
| 'reconcile_drift' | 'circuit_breaker_tripped'
|
||||
asset?: string
|
||||
reason?: string
|
||||
balance_usd?: number
|
||||
required_usd?: number
|
||||
spent_usd?: number
|
||||
cap_usd?: number
|
||||
// reconcile_drift fields (from reconciler.py broadcast)
|
||||
// orphan_hl is a list of {asset, trade_id} dicts, NOT a number
|
||||
orphan_hl?: unknown[]
|
||||
marked_closed?: number
|
||||
ghost_positions?: unknown[]
|
||||
// circuit_breaker_tripped fields (from circuit_breaker.py broadcast)
|
||||
system?: string
|
||||
cb_reason?: string
|
||||
unlock_at?: string
|
||||
}
|
||||
|
||||
// How long each alert stays visible before auto-dismissing.
|
||||
const AUTO_DISMISS_MS = 10_000
|
||||
|
||||
function alertMessage(alert: TradeAlert): string {
|
||||
switch (alert.event) {
|
||||
case 'execution_failed':
|
||||
return `Auto-trade failed${alert.asset ? ` on ${alert.asset}` : ''}${alert.reason ? `: ${alert.reason}` : ''}. Check your HL API key and account status.`
|
||||
case 'insufficient_balance':
|
||||
return `Insufficient balance to open${alert.asset ? ` ${alert.asset}` : ''}: account has $${alert.balance_usd?.toFixed(2) ?? '?'}, trade requires $${alert.required_usd?.toFixed(2) ?? '?'}. Top up your Hyperliquid account.`
|
||||
case 'budget_reached':
|
||||
return `Daily budget reached${alert.asset ? ` (${alert.asset})` : ''}: $${alert.spent_usd?.toFixed(0) ?? '?'} spent of $${alert.cap_usd?.toFixed(0) ?? '?'} cap. No more trades today.`
|
||||
// B47: reconciler detects DB ↔ HL mismatch (ghost/orphan position)
|
||||
case 'reconcile_drift': {
|
||||
const parts = []
|
||||
const orphanCount = (alert.orphan_hl as unknown[])?.length ?? 0
|
||||
if (orphanCount > 0) parts.push(`${orphanCount} orphan(s) on HL`)
|
||||
if (alert.marked_closed) parts.push(`${alert.marked_closed} auto-closed`)
|
||||
if ((alert.ghost_positions as unknown[])?.length) parts.push(`${(alert.ghost_positions as unknown[]).length} ghost(s)`)
|
||||
return `Position drift detected: ${parts.join(', ') || 'mismatch between DB and HL'}. Check your Hyperliquid account.`
|
||||
}
|
||||
// B53: circuit breaker fired — Auto-Trade suspended
|
||||
case 'circuit_breaker_tripped':
|
||||
return `Circuit breaker tripped${alert.system ? ` (${alert.system})` : ''}${alert.cb_reason ? `: ${alert.cb_reason}` : ''}. Auto-Trade suspended — turn it back ON on the Trump page to acknowledge and resume.`
|
||||
default:
|
||||
return 'Auto-trade event — check your account.'
|
||||
}
|
||||
}
|
||||
|
||||
let _seq = 0
|
||||
|
||||
export default function TradeAlertBanner() {
|
||||
const { address } = useAccount()
|
||||
const [alerts, setAlerts] = useState<TradeAlert[]>([])
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setAlerts(prev => prev.filter(a => a.id !== id))
|
||||
}, [])
|
||||
|
||||
// Auto-dismiss each alert after AUTO_DISMISS_MS.
|
||||
// Effect re-runs whenever the alert list changes. For each alert we schedule
|
||||
// a timeout; cleanup cancels all pending timers when the list updates or
|
||||
// the component unmounts — prevents a dismissed-then-re-added alert from
|
||||
// triggering a ghost dismiss.
|
||||
useEffect(() => {
|
||||
if (alerts.length === 0) return
|
||||
const timers = alerts.map(a =>
|
||||
window.setTimeout(() => dismiss(a.id), AUTO_DISMISS_MS)
|
||||
)
|
||||
return () => timers.forEach(clearTimeout)
|
||||
}, [alerts, dismiss])
|
||||
|
||||
useWsSubscribe('trade_alert', useCallback((raw) => {
|
||||
const msg = raw as { wallet?: string; event?: string } & Record<string, unknown>
|
||||
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
|
||||
const alert = { id: ++_seq, ...msg } as TradeAlert
|
||||
setAlerts(prev => [alert, ...prev].slice(0, 3))
|
||||
}, [address]))
|
||||
|
||||
// B47: reconcile_drift — DB↔HL position mismatch flagged by the reconciler.
|
||||
useWsSubscribe('reconcile_drift', useCallback((raw) => {
|
||||
const msg = raw as { wallet?: string } & Record<string, unknown>
|
||||
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
|
||||
const alert = { id: ++_seq, event: 'reconcile_drift' as const, ...msg } as TradeAlert
|
||||
setAlerts(prev => [alert, ...prev].slice(0, 3))
|
||||
}, [address]))
|
||||
|
||||
// B53: circuit_breaker_tripped — Auto-Trade suspended by the CB.
|
||||
useWsSubscribe('circuit_breaker_tripped', useCallback((raw) => {
|
||||
const msg = raw as { wallet?: string; reason?: string } & Record<string, unknown>
|
||||
if (!address || msg.wallet?.toLowerCase() !== address.toLowerCase()) return
|
||||
// Rename 'reason' → 'cb_reason' to avoid collision with the trade_alert 'reason' field.
|
||||
const { reason: cb_reason, ...rest } = msg
|
||||
const alert = { id: ++_seq, event: 'circuit_breaker_tripped' as const, cb_reason, ...rest } as TradeAlert
|
||||
setAlerts(prev => [alert, ...prev].slice(0, 3))
|
||||
}, [address]))
|
||||
|
||||
if (alerts.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes tradeAlertIn {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-8px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
@keyframes tradeAlertOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
<div style={{
|
||||
position: 'fixed', top: 60, left: '50%', transform: 'translateX(-50%)',
|
||||
zIndex: 9999, display: 'flex', flexDirection: 'column', gap: 8,
|
||||
width: 'min(480px, calc(100vw - 32px))',
|
||||
pointerEvents: 'none',
|
||||
animation: 'tradeAlertIn 0.2s ease',
|
||||
}}>
|
||||
{alerts.map(alert => (
|
||||
<div key={alert.id} style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
padding: '12px 14px', borderRadius: 10,
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--down)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,.3)',
|
||||
pointerEvents: 'auto',
|
||||
}}>
|
||||
<span style={{ fontSize: 16, flexShrink: 0, marginTop: 1 }}>⚠️</span>
|
||||
<div style={{ flex: 1, fontSize: 12, lineHeight: 1.5, color: 'var(--ink)' }}>
|
||||
<strong style={{ color: 'var(--down)', display: 'block', marginBottom: 2 }}>
|
||||
Auto-trade blocked
|
||||
</strong>
|
||||
{alertMessage(alert)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismiss(alert.id)}
|
||||
style={{
|
||||
flexShrink: 0, padding: 0, border: 'none', background: 'transparent',
|
||||
cursor: 'pointer', color: 'var(--ink-4)', fontSize: 16, lineHeight: 1,
|
||||
}}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* MobileWalletSheet — bottom-drawer shown on mobile when no injected wallet
|
||||
* is detected. Offers deep links to common mobile wallets so the user can
|
||||
* open MetaMask (or Trust / Coinbase / OKX) and land directly on this dApp.
|
||||
*
|
||||
* Rendered as a fixed overlay + slide-up panel. Backdrop tap dismisses it.
|
||||
* The CSS animation class `mobile-sheet-enter` is defined in globals.css.
|
||||
*
|
||||
* Usage:
|
||||
* <MobileWalletSheet open={open} onClose={() => setOpen(false)} />
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getWalletLinks } from '@/lib/mobileWallet'
|
||||
import type { WalletLink } from '@/lib/mobileWallet'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function MobileWalletSheet({ open, onClose }: Props) {
|
||||
const [links, setLinks] = useState<WalletLink[]>([])
|
||||
|
||||
// Build deep links client-side only (needs window.location.href).
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setLinks(getWalletLinks(window.location.href))
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Lock body scroll while sheet is open.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Escape key closes.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
/* Backdrop */
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, zIndex: 9000,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
display: 'flex', alignItems: 'flex-end',
|
||||
backdropFilter: 'blur(4px)',
|
||||
WebkitBackdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={onClose}
|
||||
aria-modal="true"
|
||||
role="dialog"
|
||||
aria-label="Connect a wallet"
|
||||
>
|
||||
{/* Sheet */}
|
||||
<div
|
||||
className="mobile-sheet-enter"
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'var(--bg-elev)',
|
||||
borderRadius: '20px 20px 0 0',
|
||||
padding: '20px 20px 32px',
|
||||
boxShadow: '0 -8px 40px rgba(0,0,0,0.18)',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Handle bar */}
|
||||
<div style={{
|
||||
width: 40, height: 4,
|
||||
background: 'var(--line-2)',
|
||||
borderRadius: 99,
|
||||
margin: '0 auto 20px',
|
||||
}} />
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 4 }}>
|
||||
Connect a wallet
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.5 }}>
|
||||
Open the dApp inside your wallet's built-in browser to connect.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wallet list */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{links.map(link => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
padding: '14px 16px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--surface)',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
transition: 'background 120ms, border-color 120ms',
|
||||
}}
|
||||
onTouchStart={e => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = 'var(--bg-sunk)'
|
||||
}}
|
||||
onTouchEnd={e => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.background = 'var(--surface)'
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div style={{
|
||||
width: 44, height: 44,
|
||||
borderRadius: 12,
|
||||
background: link.color,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: link.abbr.length > 2 ? 12 : 20,
|
||||
fontWeight: 700,
|
||||
color: '#fff',
|
||||
flexShrink: 0,
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
{link.abbr}
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>{link.name}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ink-3)' }}>{link.hint}</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style={{ color: 'var(--ink-3)', flexShrink: 0 }}>
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* WalletConnect note */}
|
||||
<div style={{
|
||||
marginTop: 18,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
background: 'var(--bg-sunk)',
|
||||
fontSize: 12,
|
||||
color: 'var(--ink-3)',
|
||||
lineHeight: 1.55,
|
||||
}}>
|
||||
<strong style={{ color: 'var(--ink-2)' }}>Already in your wallet's browser?</strong>
|
||||
{' '}Tap Cancel to dismiss, then use the built-in browser navigation to reload the page — MetaMask should auto-detect the dApp.
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
marginTop: 14,
|
||||
width: '100%',
|
||||
padding: '14px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: 'var(--bg-sunk)',
|
||||
border: '1px solid var(--line)',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: 'var(--ink-2)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user