fix: dashboard metrics — real drawdown baseline, realized/unrealized split, risk radar fields

- Max drawdown was double-broken: the backend built the equity curve on a
  hardcoded $10k baseline (understating a small account's drawdown ~20x) and
  the frontend multiplied the already-percent value by 100 again (0.87% shown
  as -86.9%). The curve now starts from the trader's real initial balance
  (10k fallback when unknown) and the UI renders the percent once; the demo
  engine and type docs are aligned to percent semantics.
- Header now separates equity-based 'Total P/L (incl. unrealized)' from
  'Realized P/L (closed trades)' so it no longer contradicts profit factor /
  win rate, and the stats strip shows the fee-drag chain
  (gross - fees = net) plus a per-trade-labelled sharpe.
- Risk radar read a non-existent account field (total_unrealized_profit) so
  unrealized PnL always showed $0; small PnLs also render with cents now.
- Nav 'Connect Hyperliquid' turns into a green connected chip when the server
  already holds a fully-authorized exchange, instead of nagging forever from
  a browser without the localStorage flow state.
This commit is contained in:
tinkle-community
2026-07-06 00:01:44 +09:00
parent ee5917adc6
commit 026c5d3089
12 changed files with 116 additions and 28 deletions

View File

@@ -237,7 +237,7 @@ func (s *Server) handlePositionHistory(c *gin.Context) {
}
// Get statistics
stats, _ := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns)
stats, _ := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
// Get symbol stats
symbolStats, _ := store.Position().GetSymbolStatsByTraderFilters(traderIDs, traderIDPatterns, 10)

View File

@@ -48,7 +48,7 @@ func (s *Server) handleStatisticsFull(c *gin.Context) {
traderIDPatterns = append(traderIDPatterns, "%_"+userID+"_claw402_%")
}
stats, err := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns)
stats, err := store.Position().GetFullStatsByTraderFilters(traderIDs, traderIDPatterns, trader.GetInitialBalance())
if err != nil {
SafeInternalError(c, "Get full statistics", err)
return

View File

@@ -38,7 +38,9 @@ type HistorySummary struct {
func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, error) {
summary := &HistorySummary{}
fullStats, err := s.GetFullStats(traderID)
// Baseline 0 is fine here: the summary only reads counts/win-rate/PnL, not
// the drawdown that depends on it.
fullStats, err := s.GetFullStats(traderID, 0)
if err != nil {
return nil, err
}

View File

@@ -54,14 +54,16 @@ func (s *PositionStore) GetPositionStats(traderID string) (map[string]interface{
return stats, nil
}
// GetFullStats gets complete trading statistics
func (s *PositionStore) GetFullStats(traderID string) (*TraderStats, error) {
return s.GetFullStatsByTraderFilters([]string{traderID}, nil)
// GetFullStats gets complete trading statistics. startingEquity is the real
// account baseline used for the drawdown equity curve; pass 0 when unknown.
func (s *PositionStore) GetFullStats(traderID string, startingEquity float64) (*TraderStats, error) {
return s.GetFullStatsByTraderFilters([]string{traderID}, nil, startingEquity)
}
// GetFullStatsByTraderFilters gets complete trading statistics for explicit
// trader IDs plus optional legacy trader ID patterns.
func (s *PositionStore) GetFullStatsByTraderFilters(traderIDs []string, traderIDPatterns []string) (*TraderStats, error) {
// trader IDs plus optional legacy trader ID patterns. startingEquity is the
// real account baseline for the drawdown calculation; pass 0 when unknown.
func (s *PositionStore) GetFullStatsByTraderFilters(traderIDs []string, traderIDPatterns []string, startingEquity float64) (*TraderStats, error) {
stats := &TraderStats{}
var positions []TraderPosition
@@ -106,7 +108,7 @@ func (s *PositionStore) GetFullStatsByTraderFilters(traderIDs []string, traderID
stats.SharpeRatio = calculateSharpeRatioFromPnls(pnls)
}
if len(pnls) > 0 {
stats.MaxDrawdownPct = calculateMaxDrawdownFromPnls(pnls)
stats.MaxDrawdownPct = calculateMaxDrawdownFromPnls(pnls, startingEquity)
}
return stats, nil
@@ -192,13 +194,21 @@ func calculateSharpeRatioFromPnls(pnls []float64) float64 {
return mean / stdDev
}
// calculateMaxDrawdownFromPnls calculates maximum drawdown
func calculateMaxDrawdownFromPnls(pnls []float64) float64 {
// calculateMaxDrawdownFromPnls reconstructs an equity curve from the realized
// PnL sequence on top of startingEquity and returns the max peak-to-trough
// drawdown as a PERCENT (e.g. 18.5 = -18.5%). The baseline matters: the same
// $87 dip is 0.9% of a $10k account but 18% of a $480 one, so callers should
// pass the trader's real initial balance. A non-positive baseline falls back
// to a neutral $10k so the metric stays defined (but understated) when the
// account baseline is unknown.
func calculateMaxDrawdownFromPnls(pnls []float64, startingEquity float64) float64 {
if len(pnls) == 0 {
return 0
}
const startingEquity = 10000.0
if startingEquity <= 0 {
startingEquity = 10000.0
}
equity := startingEquity
peak := startingEquity
var maxDD float64

View File

@@ -126,6 +126,7 @@ func TestGetClosedPositionsByTraderFiltersIncludesLegacyAutopilotIDs(t *testing.
stats, err := positions.GetFullStatsByTraderFilters(
[]string{"current-trader"},
[]string{"%_user-123_claw402_%"},
0,
)
if err != nil {
t.Fatalf("get stats: %v", err)
@@ -134,3 +135,23 @@ func TestGetClosedPositionsByTraderFiltersIncludesLegacyAutopilotIDs(t *testing.
t.Fatalf("unexpected stats: trades=%d pnl=%.2f", stats.TotalTrades, stats.TotalPnL)
}
}
func TestCalculateMaxDrawdownUsesRealBaseline(t *testing.T) {
// +50 then -100: peak 550, trough 450 on a 500 account → 100/550 ≈ 18.18%.
pnls := []float64{50, -100}
got := calculateMaxDrawdownFromPnls(pnls, 500)
if got < 18.1 || got > 18.3 {
t.Fatalf("expected ~18.18%% drawdown on a 500 baseline, got %.2f", got)
}
// Unknown baseline falls back to the neutral 10k curve: 100/10050 ≈ 1%.
fallback := calculateMaxDrawdownFromPnls(pnls, 0)
if fallback < 0.9 || fallback > 1.1 {
t.Fatalf("expected ~1%% drawdown on the 10k fallback, got %.2f", fallback)
}
if calculateMaxDrawdownFromPnls(nil, 500) != 0 {
t.Fatalf("no trades must mean zero drawdown")
}
}

View File

@@ -612,6 +612,12 @@ func (at *AutoTrader) GetID() string {
return at.id
}
// GetInitialBalance returns the account baseline used for performance metrics
// (e.g. the drawdown equity curve).
func (at *AutoTrader) GetInitialBalance() float64 {
return at.initialBalance
}
// GetUnderlyingTrader returns the underlying Trader interface implementation
// This is used by grid trading and other components that need direct exchange access
func (at *AutoTrader) GetUnderlyingTrader() Trader {

View File

@@ -652,7 +652,7 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
}
}
// Get trading statistics for AI context
stats, err := at.store.Position().GetFullStats(at.id)
stats, err := at.store.Position().GetFullStats(at.id, at.initialBalance)
if err != nil {
at.logWarnf("⚠️ Failed to get trading stats: %v", err)
} else if stats == nil {

View File

@@ -239,6 +239,11 @@ export function HyperliquidWalletConnect({
const [agentInfo, setAgentInfo] = useState<HyperliquidAgentInfo | null>(null)
const [agentInfoLoading, setAgentInfoLoading] = useState(false)
const [hasWalletProvider, setHasWalletProvider] = useState(false)
// Address of a fully-authorized hyperliquid exchange saved on the SERVER.
// The local FlowState lives in this browser's localStorage, so a fresh
// browser would otherwise show the red "Connect" CTA even though trading
// authorization is complete and the bot is running.
const [serverExchangeAddr, setServerExchangeAddr] = useState('')
const text = useMemo(
() => ({
title: language === 'zh' ? 'Hyperliquid Wallet' : 'Hyperliquid Wallet',
@@ -300,6 +305,31 @@ export function HyperliquidWalletConnect({
saveState(state)
}, [state])
useEffect(() => {
if (!isLoggedIn) {
setServerExchangeAddr('')
return
}
let cancelled = false
api
.getExchangeConfigs()
.then((configs) => {
if (cancelled) return
const ready = configs.find(
(exchange) =>
exchange.exchange_type === 'hyperliquid' &&
exchange.enabled &&
Boolean(exchange.hyperliquidBuilderApproved) &&
(exchange.hyperliquidWalletAddr || '').trim() !== ''
)
setServerExchangeAddr(ready?.hyperliquidWalletAddr || '')
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [isLoggedIn])
useEffect(() => {
if (!isLoggedIn || !state.mainWallet) return
let cancelled = false
@@ -493,6 +523,11 @@ export function HyperliquidWalletConnect({
const complete = Boolean(
state.mainWallet && state.savedExchangeId && state.builderApproved
)
// Trigger shows "connected" when either this browser finished the flow or
// the server already holds a fully-authorized exchange.
const connectedAddr = complete
? state.mainWallet
: serverExchangeAddr || undefined
async function connectWallet() {
setError('')
@@ -898,14 +933,14 @@ export function HyperliquidWalletConnect({
type="button"
onClick={() => setOpen((value) => !value)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-bold transition-all border ${
complete
connectedAddr
? 'bg-nofx-success/10 border-nofx-success/30 text-nofx-success'
: 'bg-nofx-gold/10 border-nofx-gold/30 text-nofx-gold hover:bg-nofx-gold/20'
}`}
>
<Wallet className="w-4 h-4" />
<span>
{complete ? shortAddress(state.mainWallet) : text.connect}
{connectedAddr ? shortAddress(connectedAddr) : text.connect}
</span>
<ChevronDown className="w-4 h-4" />
</button>

View File

@@ -20,7 +20,9 @@ function fmtUsd(n: number): string {
const sign = n < 0 ? '-' : ''
if (a >= 1e6) return `${sign}$${(a / 1e6).toFixed(2)}M`
if (a >= 1e3) return `${sign}$${(a / 1e3).toFixed(1)}K`
return `${sign}$${a.toFixed(0)}`
if (a >= 100) return `${sign}$${a.toFixed(0)}`
// small PnLs matter here: -$0.22 must not render as -$0
return `${sign}$${a.toFixed(2)}`
}
function pct(n: number): string {
@@ -40,8 +42,9 @@ function utilColor(p: number): string {
interface RiskRadarProps {
positions?: Position[]
account?: { total_equity?: number; total_unrealized_profit?: number; margin_used_pct?: number } | null
account?: { total_equity?: number; unrealized_profit?: number; margin_used_pct?: number } | null
config?: { btc_eth_leverage?: number; altcoin_leverage?: number; max_positions?: number } | null
/** max_drawdown_pct is a percent (18.5 = -18.5%), not a fraction. */
fullStats?: { max_drawdown_pct?: number; profit_factor?: number; sharpe_ratio?: number; win_rate?: number } | null
}
@@ -93,14 +96,13 @@ export function RiskRadar({ positions, account, config, fullStats }: RiskRadarPr
const concentration = totalNotional > 0 ? (topNotional / totalNotional) * 100 : 0
const ddFrac = fullStats?.max_drawdown_pct ?? 0
const drawdown = ddFrac * 100
const drawdown = fullStats?.max_drawdown_pct ?? 0
const count = pos.length
const maxPositions = config?.max_positions ?? 0
const countUse = maxPositions > 0 ? Math.min(100, (count / maxPositions) * 100) : 0
const upnl = account?.total_unrealized_profit ?? 0
const upnl = account?.unrealized_profit ?? 0
return {
longNotional,

View File

@@ -339,14 +339,21 @@ export function TerminalDashboard({
</div>
<div className="tm-rule" />
{/* metric row */}
{/* metric row — "Total P/L" is equity-based (includes unrealized);
"Realized P/L" is closed-trades only and matches PF/win-rate/sharpe,
so the two never read as contradicting each other */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)' }}>
{[
{ l: 'Equity', v: fmtUsd(account?.total_equity), c: 'var(--tm-ink)' },
{ l: 'Total P/L', v: `${fmtUsd(pnl, true)} (${fmtPct(pnlPct)})`, c: up ? 'var(--tm-up)' : 'var(--tm-dn)' },
{ l: 'Win rate', v: fullStats != null ? `${fullStats.win_rate.toFixed(1)}%` : '—', c: 'var(--tm-ink)' },
{ l: 'Total P/L · incl. unrealized', v: `${fmtUsd(pnl, true)} (${fmtPct(pnlPct)})`, c: up ? 'var(--tm-up)' : 'var(--tm-dn)' },
{
l: 'Realized P/L · closed trades',
v: fullStats != null ? fmtUsd(fullStats.total_pnl, true) : '—',
c: fullStats != null && fullStats.total_pnl >= 0 ? 'var(--tm-up)' : 'var(--tm-dn)',
},
{ l: 'Profit factor', v: fullStats != null ? fullStats.profit_factor.toFixed(2) : '—', c: 'var(--tm-ink)' },
{ l: 'Max drawdown', v: fullStats != null ? `-${(fullStats.max_drawdown_pct * 100).toFixed(1)}%` : '—', c: 'var(--tm-dn)' },
// max_drawdown_pct is already a percent (18.5 = -18.5%)
{ l: 'Max drawdown', v: fullStats != null ? `-${fullStats.max_drawdown_pct.toFixed(1)}%` : '—', c: 'var(--tm-dn)' },
].map((m, i) => (
<div key={m.l} style={{ padding: '12px 14px', borderRight: i < 4 ? cellBorder : 'none' }}>
<div className="tm-sc">{m.l}</div>
@@ -361,11 +368,14 @@ export function TerminalDashboard({
<>
<div className="tm-mono" style={{ display: 'flex', gap: 18, padding: '6px 14px', fontSize: 11, color: 'var(--tm-ink-2)', flexWrap: 'wrap' }}>
<span className="tm-sc">trades <b style={{ color: 'var(--tm-ink)' }}>{fullStats.total_trades}</b></span>
<span className="tm-sc tm-up">win {fullStats.win_trades}</span>
<span className="tm-sc tm-up">win {fullStats.win_trades} ({fullStats.win_rate.toFixed(1)}%)</span>
<span className="tm-sc tm-dn">loss {fullStats.loss_trades}</span>
<span className="tm-sc">sharpe <b style={{ color: 'var(--tm-ink)' }}>{fullStats.sharpe_ratio.toFixed(2)}</b></span>
{/* fee-drag chain: gross realized fees = net realized */}
<span className="tm-sc">gross <b style={{ color: 'var(--tm-ink)' }}>{fmtUsd(fullStats.total_pnl + fullStats.total_fee, true)}</b></span>
<span className="tm-sc">fees <b style={{ color: 'var(--tm-ink)' }}>-{fmtUsd(fullStats.total_fee)}</b></span>
<span className="tm-sc">net <b style={{ color: fullStats.total_pnl >= 0 ? 'var(--tm-up)' : 'var(--tm-dn)' }}>{fmtUsd(fullStats.total_pnl, true)}</b></span>
<span className="tm-sc">sharpe/trade <b style={{ color: 'var(--tm-ink)' }}>{fullStats.sharpe_ratio.toFixed(2)}</b></span>
<span className="tm-sc">avg win/loss <b style={{ color: 'var(--tm-ink)' }}>{fullStats.avg_win.toFixed(2)}/{fullStats.avg_loss.toFixed(2)}</b></span>
<span className="tm-sc">fees <b style={{ color: 'var(--tm-ink)' }}>{fmtUsd(fullStats.total_fee)}</b></span>
</div>
<div className="tm-rule" />
</>

View File

@@ -282,7 +282,8 @@ function build(S: SimState): DemoDataset {
total_fee: S.fee,
avg_win: avgWin,
avg_loss: avgLoss,
max_drawdown_pct: 0.052,
// percent semantics (5.2 = -5.2%), matching the real stats endpoint
max_drawdown_pct: 5.2,
}
// recent closed trades

View File

@@ -110,6 +110,7 @@ export interface TraderFullStats {
total_fee: number
avg_win: number
avg_loss: number
/** Percent, not a fraction: 18.5 means -18.5% peak drawdown. */
max_drawdown_pct: number
}