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
This commit is contained in:
tinkle-community
2025-12-28 21:05:18 +08:00
parent 1c32c2ab08
commit d74867c220
5 changed files with 250 additions and 0 deletions
+55
View File
@@ -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)
+20
View File
@@ -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({
</div>
{/* 右侧结束 */}
</div>
{/* Position History Section */}
{selectedTraderId && (
<div
className="binance-card p-6 animate-slide-in"
style={{ animationDelay: '0.25s' }}
>
<div className="flex items-center justify-between mb-5">
<h2
className="text-xl font-bold flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
<span className="text-2xl">📜</span>
{t('positionHistory.title', language)}
</h2>
</div>
<PositionHistory traderId={selectedTraderId} />
</div>
)}
</div>
)
}
+98
View File
@@ -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: '行情辩论大赛',
+10
View File
@@ -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<PositionHistoryResponse> {
const result = await httpClient.get<PositionHistoryResponse>(
`${API_BASE}/positions/history?trader_id=${traderId}&limit=${limit}`
)
if (!result.success) throw new Error('获取历史仓位失败')
return result.data!
},
}
+67
View File
@@ -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[];
}