From d74867c220af985df8f97969ca3c20c070a0b3b3 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sun, 28 Dec 2025 21:05:18 +0800 Subject: [PATCH] feat: add position history API and frontend integration - Add /positions/history API endpoint - Add position history types and API client - Add translations for position history page - Integrate PositionHistory component in App --- api/server.go | 55 ++++++++++++++++++++ web/src/App.tsx | 20 ++++++++ web/src/i18n/translations.ts | 98 ++++++++++++++++++++++++++++++++++++ web/src/lib/api.ts | 10 ++++ web/src/types.ts | 67 ++++++++++++++++++++++++ 5 files changed, 250 insertions(+) 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[]; +}