diff --git a/api/handler_order.go b/api/handler_order.go index b44d7883..d17a347b 100644 --- a/api/handler_order.go +++ b/api/handler_order.go @@ -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) diff --git a/api/handler_stats_full.go b/api/handler_stats_full.go index a99aae74..8ebeaeaf 100644 --- a/api/handler_stats_full.go +++ b/api/handler_stats_full.go @@ -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 diff --git a/store/position_history.go b/store/position_history.go index d217839f..b8e80b3e 100644 --- a/store/position_history.go +++ b/store/position_history.go @@ -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 } diff --git a/store/position_query.go b/store/position_query.go index c642df9a..a01cbfdd 100644 --- a/store/position_query.go +++ b/store/position_query.go @@ -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 diff --git a/store/position_test.go b/store/position_test.go index aba29e0d..b6275ce8 100644 --- a/store/position_test.go +++ b/store/position_test.go @@ -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") + } +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index ced0ff18..4092bc52 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -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 { diff --git a/trader/auto_trader_loop.go b/trader/auto_trader_loop.go index 3a6739f9..a6bf5c26 100644 --- a/trader/auto_trader_loop.go +++ b/trader/auto_trader_loop.go @@ -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 { diff --git a/web/src/components/common/HyperliquidWalletConnect.tsx b/web/src/components/common/HyperliquidWalletConnect.tsx index af585eae..b2e36fcf 100644 --- a/web/src/components/common/HyperliquidWalletConnect.tsx +++ b/web/src/components/common/HyperliquidWalletConnect.tsx @@ -239,6 +239,11 @@ export function HyperliquidWalletConnect({ const [agentInfo, setAgentInfo] = useState(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' }`} > - {complete ? shortAddress(state.mainWallet) : text.connect} + {connectedAddr ? shortAddress(connectedAddr) : text.connect} diff --git a/web/src/components/terminal/RiskRadar.tsx b/web/src/components/terminal/RiskRadar.tsx index 8fc371aa..c67aefea 100644 --- a/web/src/components/terminal/RiskRadar.tsx +++ b/web/src/components/terminal/RiskRadar.tsx @@ -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, diff --git a/web/src/components/terminal/TerminalDashboard.tsx b/web/src/components/terminal/TerminalDashboard.tsx index ca5af174..22d7f7ad 100644 --- a/web/src/components/terminal/TerminalDashboard.tsx +++ b/web/src/components/terminal/TerminalDashboard.tsx @@ -339,14 +339,21 @@ export function TerminalDashboard({
- {/* 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 */}
{[ { 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) => (
{m.l}
@@ -361,11 +368,14 @@ export function TerminalDashboard({ <>
trades {fullStats.total_trades} - win {fullStats.win_trades} + win {fullStats.win_trades} ({fullStats.win_rate.toFixed(1)}%) loss {fullStats.loss_trades} - sharpe {fullStats.sharpe_ratio.toFixed(2)} + {/* fee-drag chain: gross realized − fees = net realized */} + gross {fmtUsd(fullStats.total_pnl + fullStats.total_fee, true)} + fees -{fmtUsd(fullStats.total_fee)} + net = 0 ? 'var(--tm-up)' : 'var(--tm-dn)' }}>{fmtUsd(fullStats.total_pnl, true)} + sharpe/trade {fullStats.sharpe_ratio.toFixed(2)} avg win/loss {fullStats.avg_win.toFixed(2)}/{fullStats.avg_loss.toFixed(2)} - fees {fmtUsd(fullStats.total_fee)}
diff --git a/web/src/lib/demo/useDemoEngine.ts b/web/src/lib/demo/useDemoEngine.ts index 89c8bb48..3368565c 100644 --- a/web/src/lib/demo/useDemoEngine.ts +++ b/web/src/lib/demo/useDemoEngine.ts @@ -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 diff --git a/web/src/types/trading.ts b/web/src/types/trading.ts index f2bd17b3..bde02db5 100644 --- a/web/src/types/trading.ts +++ b/web/src/types/trading.ts @@ -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 }