diff --git a/api/server.go b/api/server.go
index bf1752cb..4790d2a8 100644
--- a/api/server.go
+++ b/api/server.go
@@ -191,6 +191,7 @@ func (s *Server) setupRoutes() {
protected.GET("/status", s.handleStatus)
protected.GET("/account", s.handleAccount)
protected.GET("/positions", s.handlePositions)
+ protected.GET("/positions/history", s.handlePositionHistory)
protected.GET("/trades", s.handleTrades)
protected.GET("/orders", s.handleOrders) // Order list (all orders)
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
@@ -2123,6 +2124,60 @@ func (s *Server) handlePositions(c *gin.Context) {
c.JSON(http.StatusOK, positions)
}
+// handlePositionHistory Historical closed positions with statistics
+func (s *Server) handlePositionHistory(c *gin.Context) {
+ _, traderID, err := s.getTraderFromQuery(c)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ trader, err := s.traderManager.GetTrader(traderID)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+
+ // Get optional query parameters
+ limitStr := c.DefaultQuery("limit", "100")
+ limit := 100
+ if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
+ limit = l
+ }
+
+ // Get store
+ store := trader.GetStore()
+ if store == nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
+ return
+ }
+
+ // Get closed positions
+ positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": fmt.Sprintf("Failed to get position history: %v", err),
+ })
+ return
+ }
+
+ // Get statistics
+ stats, _ := store.Position().GetFullStats(trader.GetID())
+
+ // Get symbol stats
+ symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10)
+
+ // Get direction stats
+ directionStats, _ := store.Position().GetDirectionStats(trader.GetID())
+
+ c.JSON(http.StatusOK, gin.H{
+ "positions": positions,
+ "stats": stats,
+ "symbol_stats": symbolStats,
+ "direction_stats": directionStats,
+ })
+}
+
// handleTrades Historical trades list
func (s *Server) handleTrades(c *gin.Context) {
_, traderID, err := s.getTraderFromQuery(c)
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 5ae009c6..1e636de2 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -19,6 +19,7 @@ import { t, type Language } from './i18n/translations'
import { confirmToast, notify } from './lib/notify'
import { useSystemConfig } from './hooks/useSystemConfig'
import { DecisionCard } from './components/DecisionCard'
+import { PositionHistory } from './components/PositionHistory'
import { PunkAvatar, getTraderAvatar } from './components/PunkAvatar'
import { OFFICIAL_LINKS } from './constants/branding'
import { BacktestPage } from './components/BacktestPage'
@@ -1514,6 +1515,25 @@ function TraderDetailsPage({
{/* 右侧结束 */}
+
+ {/* Position History Section */}
+ {selectedTraderId && (
+
+
+
+ 📜
+ {t('positionHistory.title', language)}
+
+
+
+
+ )}
)
}
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 50c866a0..e02e01ec 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1091,6 +1091,55 @@ export const translations = {
privatekeyObfuscationFailed: 'Clipboard obfuscation failed',
},
+ // Position History
+ positionHistory: {
+ title: 'Position History',
+ loading: 'Loading position history...',
+ noHistory: 'No Position History',
+ noHistoryDesc: 'Closed positions will appear here after trading.',
+ showingPositions: 'Showing {count} of {total} positions',
+ totalPnL: 'Total P&L',
+ // Stats
+ totalTrades: 'Total Trades',
+ winLoss: 'Win: {win} / Loss: {loss}',
+ winRate: 'Win Rate',
+ profitFactor: 'Profit Factor',
+ profitFactorDesc: 'Total Profit / Total Loss',
+ plRatio: 'P/L Ratio',
+ plRatioDesc: 'Avg Win / Avg Loss',
+ sharpeRatio: 'Sharpe Ratio',
+ sharpeRatioDesc: 'Risk-adjusted Return',
+ maxDrawdown: 'Max Drawdown',
+ avgWin: 'Avg Win',
+ avgLoss: 'Avg Loss',
+ netPnL: 'Net P&L',
+ netPnLDesc: 'After Fees',
+ fee: 'Fee',
+ // Direction Stats
+ trades: 'Trades',
+ avgPnL: 'Avg P&L',
+ // Symbol Performance
+ symbolPerformance: 'Symbol Performance',
+ // Filters
+ symbol: 'Symbol',
+ allSymbols: 'All Symbols',
+ side: 'Side',
+ all: 'All',
+ sort: 'Sort',
+ latestFirst: 'Latest First',
+ oldestFirst: 'Oldest First',
+ highestPnL: 'Highest P&L',
+ lowestPnL: 'Lowest P&L',
+ // Table Headers
+ entry: 'Entry',
+ exit: 'Exit',
+ qty: 'Qty',
+ lev: 'Lev',
+ pnl: 'P&L',
+ duration: 'Duration',
+ closedAt: 'Closed At',
+ },
+
// Debate Arena Page
debatePage: {
title: 'Market Debate Arena',
@@ -2188,6 +2237,55 @@ export const translations = {
privatekeyObfuscationFailed: '剪贴板混淆失败',
},
+ // Position History
+ positionHistory: {
+ title: '历史仓位',
+ loading: '加载历史仓位...',
+ noHistory: '暂无历史仓位',
+ noHistoryDesc: '平仓后的仓位记录将显示在此处',
+ showingPositions: '显示 {count} / {total} 条记录',
+ totalPnL: '总盈亏',
+ // Stats
+ totalTrades: '总交易次数',
+ winLoss: '盈利: {win} / 亏损: {loss}',
+ winRate: '胜率',
+ profitFactor: '盈利因子',
+ profitFactorDesc: '总盈利 / 总亏损',
+ plRatio: '盈亏比',
+ plRatioDesc: '平均盈利 / 平均亏损',
+ sharpeRatio: '夏普比率',
+ sharpeRatioDesc: '风险调整收益',
+ maxDrawdown: '最大回撤',
+ avgWin: '平均盈利',
+ avgLoss: '平均亏损',
+ netPnL: '净盈亏',
+ netPnLDesc: '扣除手续费后',
+ fee: '手续费',
+ // Direction Stats
+ trades: '交易次数',
+ avgPnL: '平均盈亏',
+ // Symbol Performance
+ symbolPerformance: '品种表现',
+ // Filters
+ symbol: '交易对',
+ allSymbols: '全部交易对',
+ side: '方向',
+ all: '全部',
+ sort: '排序',
+ latestFirst: '最新优先',
+ oldestFirst: '最早优先',
+ highestPnL: '盈利最高',
+ lowestPnL: '亏损最多',
+ // Table Headers
+ entry: '开仓价',
+ exit: '平仓价',
+ qty: '数量',
+ lev: '杠杆',
+ pnl: '盈亏',
+ duration: '持仓时长',
+ closedAt: '平仓时间',
+ },
+
// Debate Arena Page
debatePage: {
title: '行情辩论大赛',
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 088f382a..84024439 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -29,6 +29,7 @@ import type {
DebateMessage,
DebateVote,
DebatePersonalityInfo,
+ PositionHistoryResponse,
} from '../types'
import { CryptoService } from './crypto'
import { httpClient } from './httpClient'
@@ -775,4 +776,13 @@ export const api = {
const token = localStorage.getItem('auth_token')
return new EventSource(`${API_BASE}/debates/${debateId}/stream?token=${token}`)
},
+
+ // Position History API
+ async getPositionHistory(traderId: string, limit: number = 100): Promise {
+ const result = await httpClient.get(
+ `${API_BASE}/positions/history?trader_id=${traderId}&limit=${limit}`
+ )
+ if (!result.success) throw new Error('获取历史仓位失败')
+ return result.data!
+ },
}
diff --git a/web/src/types.ts b/web/src/types.ts
index 63bae385..046e40ad 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -645,3 +645,70 @@ export interface DebatePersonalityInfo {
color: string;
description: string;
}
+
+// Position History Types
+export interface HistoricalPosition {
+ id: number;
+ trader_id: string;
+ exchange_id: string;
+ exchange_type: string;
+ symbol: string;
+ side: string;
+ quantity: number;
+ entry_quantity: number;
+ entry_price: number;
+ entry_order_id: string;
+ entry_time: string;
+ exit_price: number;
+ exit_order_id: string;
+ exit_time: string;
+ realized_pnl: number;
+ fee: number;
+ leverage: number;
+ status: string;
+ close_reason: string;
+ created_at: string;
+ updated_at: string;
+}
+
+// Matches Go TraderStats struct exactly
+export interface TraderStats {
+ total_trades: number;
+ win_trades: number;
+ loss_trades: number;
+ win_rate: number;
+ profit_factor: number;
+ sharpe_ratio: number;
+ total_pnl: number;
+ total_fee: number;
+ avg_win: number;
+ avg_loss: number;
+ max_drawdown_pct: number;
+}
+
+// Matches Go SymbolStats struct exactly
+export interface SymbolStats {
+ symbol: string;
+ total_trades: number;
+ win_trades: number;
+ win_rate: number;
+ total_pnl: number;
+ avg_pnl: number;
+ avg_hold_mins: number;
+}
+
+// Matches Go DirectionStats struct exactly
+export interface DirectionStats {
+ side: string;
+ trade_count: number;
+ win_rate: number;
+ total_pnl: number;
+ avg_pnl: number;
+}
+
+export interface PositionHistoryResponse {
+ positions: HistoricalPosition[];
+ stats: TraderStats | null;
+ symbol_stats: SymbolStats[];
+ direction_stats: DirectionStats[];
+}