mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-06 04:20:59 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user