From bd74f9447fd42ceeae1e367c9c4b04ac629eb6cf Mon Sep 17 00:00:00 2001
From: Ajit Pratap Singh
Date: Thu, 19 Mar 2026 21:26:19 +0530
Subject: [PATCH 01/19] test(paper-trade): failing integration test +
ListAllTrades DB helper
---
cmd/api/paper_trade_pipeline_test.go | 59 ++++++++++++++++++++++++++++
internal/db/trades.go | 36 +++++++++++++++++
2 files changed, 95 insertions(+)
create mode 100644 cmd/api/paper_trade_pipeline_test.go
create mode 100644 internal/db/trades.go
diff --git a/cmd/api/paper_trade_pipeline_test.go b/cmd/api/paper_trade_pipeline_test.go
new file mode 100644
index 0000000..6d71a0b
--- /dev/null
+++ b/cmd/api/paper_trade_pipeline_test.go
@@ -0,0 +1,59 @@
+//go:build integration
+
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPaperTrade_PersistsAllRows(t *testing.T) {
+ srv, _ := setupTestAPIServer(t)
+
+ body := `{"symbol":"BTCUSDT","side":"BUY","type":"market","quantity":0.1,"price":45000}`
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/trade", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ srv.router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusCreated, w.Code)
+ var resp map[string]interface{}
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
+ orderID := resp["order"].(map[string]interface{})["id"].(string)
+
+ ctx := context.Background()
+
+ // Verify order row is filled with non-zero executed_quote_quantity
+ order, err := srv.db.GetOrder(ctx, uuid.MustParse(orderID))
+ require.NoError(t, err)
+ assert.Equal(t, "FILLED", string(order.Status))
+ assert.Greater(t, order.ExecutedQuantity, 0.0)
+ assert.Greater(t, order.ExecutedQuoteQuantity, 0.0, "executed_quote_quantity must be non-zero (price bug)")
+
+ // Verify trade fill row via GetTradesByOrderID (already exists in orders.go)
+ fills, err := srv.db.GetTradesByOrderID(ctx, uuid.MustParse(orderID))
+ require.NoError(t, err)
+ assert.NotEmpty(t, fills, "expected at least one trade fill row in trades table")
+ assert.Greater(t, fills[0].Price, 0.0, "fill price must be > 0")
+
+ // Verify open position exists
+ positions, err := srv.db.GetAllOpenPositions(ctx)
+ require.NoError(t, err)
+ found := false
+ for _, p := range positions {
+ if p.Symbol == "BTCUSDT" {
+ found = true
+ assert.Greater(t, p.EntryPrice, 0.0)
+ assert.Greater(t, p.Quantity, 0.0)
+ }
+ }
+ assert.True(t, found, "expected BTCUSDT open position after paper trade")
+}
diff --git a/internal/db/trades.go b/internal/db/trades.go
new file mode 100644
index 0000000..53d3d17
--- /dev/null
+++ b/internal/db/trades.go
@@ -0,0 +1,36 @@
+package db
+
+import (
+ "context"
+)
+
+// ListAllTrades returns recent trade fills across all orders, newest first.
+// For fills by specific order, use GetTradesByOrderID in orders.go instead.
+func (db *DB) ListAllTrades(ctx context.Context, limit, offset int) ([]*Trade, error) {
+ rows, err := db.pool.Query(ctx, `
+ SELECT id, order_id, exchange_trade_id, symbol, exchange, side,
+ price, quantity, quote_quantity, commission, commission_asset,
+ executed_at, is_maker, metadata, created_at
+ FROM trades
+ ORDER BY executed_at DESC
+ LIMIT $1 OFFSET $2
+ `, limit, offset)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var trades []*Trade
+ for rows.Next() {
+ t := &Trade{}
+ if err := rows.Scan(
+ &t.ID, &t.OrderID, &t.ExchangeTradeID, &t.Symbol, &t.Exchange, &t.Side,
+ &t.Price, &t.Quantity, &t.QuoteQuantity, &t.Commission, &t.CommissionAsset,
+ &t.ExecutedAt, &t.IsMaker, &t.Metadata, &t.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ trades = append(trades, t)
+ }
+ return trades, rows.Err()
+}
From ba794d9f3025080120b29798421865128570dd62 Mon Sep 17 00:00:00 2001
From: Ajit Pratap Singh
Date: Thu, 19 Mar 2026 21:28:58 +0530
Subject: [PATCH 02/19] feat(api): add GET /api/v1/trades endpoint for fill
records
Adds TradesHandler in internal/api/trades.go with ListTrades supporting
limit/offset query params, and registers it in cmd/api/routes.go after
the dashboard handler block.
Co-Authored-By: Claude Sonnet 4.6
---
cmd/api/routes.go | 4 +++
internal/api/trades.go | 57 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 61 insertions(+)
create mode 100644 internal/api/trades.go
diff --git a/cmd/api/routes.go b/cmd/api/routes.go
index 5ac451d..8c8b902 100644
--- a/cmd/api/routes.go
+++ b/cmd/api/routes.go
@@ -236,6 +236,10 @@ func (s *APIServer) setupRoutes() {
dashboardHandler := api.NewDashboardHandlerWithOrchestrator(s.db, orchClient, config.Version)
dashboardHandler.RegisterRoutesWithRateLimiter(v1, s.rateLimiter.ReadMiddleware(), s.rateLimiter.ControlMiddleware())
+ // Trades routes — fill records from the trades table
+ tradesHandler := api.NewTradesHandler(s.db)
+ tradesHandler.RegisterRoutes(v1, s.rateLimiter.ReadMiddleware())
+
// TB-006: API Key Management routes
// These endpoints allow users to manage their API keys (create, rotate, revoke)
// All key management operations require authentication
diff --git a/internal/api/trades.go b/internal/api/trades.go
new file mode 100644
index 0000000..4fbf93c
--- /dev/null
+++ b/internal/api/trades.go
@@ -0,0 +1,57 @@
+package api
+
+import (
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ajitpratap0/cryptofunk/internal/db"
+)
+
+// TradesHandler serves fill records from the trades table.
+type TradesHandler struct {
+ db *db.DB
+}
+
+func NewTradesHandler(database *db.DB) *TradesHandler {
+ return &TradesHandler{db: database}
+}
+
+func (h *TradesHandler) RegisterRoutes(rg *gin.RouterGroup, readMiddleware gin.HandlerFunc) {
+ trades := rg.Group("/trades")
+ trades.Use(readMiddleware)
+ trades.GET("", h.ListTrades)
+}
+
+// ListTrades returns recent trade fills, newest first.
+// GET /api/v1/trades?limit=50&offset=0
+func (h *TradesHandler) ListTrades(c *gin.Context) {
+ limit := 50
+ offset := 0
+ if l := c.Query("limit"); l != "" {
+ if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 500 {
+ limit = v
+ }
+ }
+ if o := c.Query("offset"); o != "" {
+ if v, err := strconv.Atoi(o); err == nil && v >= 0 {
+ offset = v
+ }
+ }
+
+ trades, err := h.db.ListAllTrades(c.Request.Context(), limit, offset)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch trades"})
+ return
+ }
+ if trades == nil {
+ trades = []*db.Trade{}
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "trades": trades,
+ "count": len(trades),
+ "limit": limit,
+ "offset": offset,
+ })
+}
From ffe7404ee8d2711ca999e2c7b223e4f36759e89d Mon Sep 17 00:00:00 2001
From: Ajit Pratap Singh
Date: Thu, 19 Mar 2026 21:29:11 +0530
Subject: [PATCH 03/19] fix(api): handlePaperTrade persists trades, positions,
and session stats correctly
Co-Authored-By: Claude Sonnet 4.6
---
cmd/api/handlers_trading.go | 176 ++++++++++++++++++++++++++++++------
1 file changed, 149 insertions(+), 27 deletions(-)
diff --git a/cmd/api/handlers_trading.go b/cmd/api/handlers_trading.go
index 8d9dff3..a0fcff3 100644
--- a/cmd/api/handlers_trading.go
+++ b/cmd/api/handlers_trading.go
@@ -1,17 +1,23 @@
package main
import (
+ "errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
"github.com/rs/zerolog/log"
"github.com/ajitpratap0/cryptofunk/internal/db"
+ "github.com/ajitpratap0/cryptofunk/internal/exchange"
)
+func ptrStr(s string) *string { return &s }
+func ptrF64(f float64) *float64 { return &f }
+
// Session handlers
func (s *APIServer) handleListSessions(c *gin.Context) {
ctx := c.Request.Context()
@@ -389,57 +395,110 @@ func (s *APIServer) handlePaperTrade(c *gin.Context) {
Quantity float64 `json:"quantity" binding:"required,gt=0"`
Price float64 `json:"price"`
}
-
if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{
- "error": "invalid request body",
- "details": err.Error(),
- })
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body", "details": err.Error()})
+ return
+ }
+ isLimit := strings.EqualFold(req.Type, "limit")
+ if isLimit && req.Price <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "price is required for limit orders"})
return
}
- if (req.Type == "limit" || req.Type == "LIMIT") && req.Price <= 0 {
- c.JSON(http.StatusBadRequest, gin.H{
- "error": "price is required for limit orders",
- })
+ ctx := c.Request.Context()
+
+ // 1. Resolve or create paper session
+ sessions, err := s.db.ListActiveSessions(ctx)
+ if err != nil {
+ log.Error().Err(err).Msg("Failed to list active sessions for paper trade")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve trading session"})
return
}
+ var sessionID *uuid.UUID
+ for i := range sessions {
+ if sessions[i].Mode == db.TradingModePaper {
+ id := sessions[i].ID
+ sessionID = &id
+ break
+ }
+ }
+ if sessionID == nil {
+ newSession := &db.TradingSession{
+ ID: uuid.New(),
+ Mode: db.TradingModePaper,
+ Symbol: req.Symbol,
+ Exchange: "paper",
+ InitialCapital: 100_000.0,
+ StartedAt: time.Now(),
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ if err := s.db.CreateSession(ctx, newSession); err != nil {
+ log.Error().Err(err).Msg("Failed to create paper session")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create trading session"})
+ return
+ }
+ sessionID = &newSession.ID
+ }
- var price *float64
+ // 2. Determine execution price
+ refPrice := req.Price
+ if !isLimit && refPrice <= 0 {
+ mockEx := exchange.NewMockExchange(s.db)
+ refPrice = mockEx.GetMarketPrice(req.Symbol)
+ if refPrice <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "no market price configured for symbol; provide a price field",
+ })
+ return
+ }
+ }
+ execPrice := refPrice
+ if !isLimit {
+ if strings.EqualFold(req.Side, "BUY") {
+ execPrice = refPrice * 1.001
+ } else {
+ execPrice = refPrice * 0.999
+ }
+ }
+
+ // 3. Insert order
+ now := time.Now()
+ var pricePtr *float64
if req.Price > 0 {
- price = &req.Price
+ pricePtr = ptrF64(req.Price)
}
+ orderSide := db.ConvertOrderSide(req.Side)
+ orderType := db.ConvertOrderType(req.Type)
order := &db.Order{
ID: uuid.New(),
+ SessionID: sessionID,
Symbol: req.Symbol,
Exchange: "paper",
- Side: db.ConvertOrderSide(req.Side),
- Type: db.ConvertOrderType(req.Type),
+ Side: orderSide,
+ Type: orderType,
Quantity: req.Quantity,
- Price: price,
+ Price: pricePtr,
Status: db.OrderStatusNew,
- PlacedAt: time.Now(),
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
+ PlacedAt: now,
+ CreatedAt: now,
+ UpdatedAt: now,
}
-
- ctx := c.Request.Context()
if err := s.db.InsertOrder(ctx, order); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{
- "error": "failed to create paper trade order",
- })
+ log.Error().Err(err).Msg("Failed to insert paper trade order")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create paper trade order"})
return
}
- // Simulate immediate fill for market orders
+ // 4. Immediate fill for market orders
if order.Type == db.OrderTypeMarket {
- now := time.Now()
- execPrice := req.Price
execQuoteQty := execPrice * req.Quantity
+ commission := execQuoteQty * 0.001
- if err := s.db.UpdateOrderStatus(ctx, order.ID, db.OrderStatusFilled, req.Quantity, execQuoteQty, &now, nil, nil); err != nil {
- log.Warn().Err(err).Str("order_id", order.ID.String()).Msg("Failed to mark paper trade order as filled")
+ if err := s.db.UpdateOrderStatus(ctx, order.ID, db.OrderStatusFilled,
+ req.Quantity, execQuoteQty, &now, nil, nil); err != nil {
+ log.Warn().Err(err).Str("order_id", order.ID.String()).Msg("Failed to mark paper order filled")
} else {
order.Status = db.OrderStatusFilled
order.ExecutedQuantity = req.Quantity
@@ -447,6 +506,69 @@ func (s *APIServer) handlePaperTrade(c *gin.Context) {
order.FilledAt = &now
order.UpdatedAt = now
}
+
+ // Write fill record
+ commissionAsset := "USDT"
+ trade := &db.Trade{
+ ID: uuid.New(),
+ OrderID: order.ID,
+ Symbol: req.Symbol,
+ Exchange: "paper",
+ Side: orderSide,
+ Price: execPrice,
+ Quantity: req.Quantity,
+ QuoteQuantity: execQuoteQty,
+ Commission: commission,
+ CommissionAsset: &commissionAsset,
+ ExecutedAt: now,
+ IsMaker: false,
+ CreatedAt: now,
+ }
+ if err := s.db.InsertTrade(ctx, trade); err != nil {
+ log.Warn().Err(err).Msg("Failed to insert paper trade fill row")
+ }
+
+ // Create or average into existing position
+ existingPos, posErr := s.db.GetPositionBySymbolAndSession(ctx, req.Symbol, *sessionID)
+ if posErr != nil && !errors.Is(posErr, pgx.ErrNoRows) {
+ log.Warn().Err(posErr).Msg("Error looking up existing position")
+ }
+
+ posSide := db.PositionSideLong
+ if orderSide == db.OrderSideSell {
+ posSide = db.PositionSideShort
+ }
+
+ if existingPos == nil {
+ pos := &db.Position{
+ ID: uuid.New(),
+ SessionID: sessionID,
+ Symbol: req.Symbol,
+ Exchange: "paper",
+ Side: posSide,
+ EntryPrice: execPrice,
+ Quantity: req.Quantity,
+ EntryTime: now,
+ Fees: commission,
+ EntryReason: ptrStr("paper_trade_api"),
+ UnrealizedPnL: ptrF64(0),
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := s.db.CreatePosition(ctx, pos); err != nil {
+ log.Warn().Err(err).Msg("Failed to create position for paper trade")
+ }
+ } else {
+ totalQty := existingPos.Quantity + req.Quantity
+ weightedAvg := (existingPos.Quantity*existingPos.EntryPrice + req.Quantity*execPrice) / totalQty
+ if err := s.db.UpdatePositionAveraging(ctx, existingPos.ID, weightedAvg, totalQty, commission); err != nil {
+ log.Warn().Err(err).Msg("Failed to update position for paper trade")
+ }
+ }
+
+ if err := s.db.AggregateSessionStats(ctx, *sessionID); err != nil {
+ log.Warn().Err(err).Msg("Failed to aggregate session stats after paper trade")
+ }
}
if err := s.BroadcastOrderUpdate(order); err != nil {
From 2f406f2d7fec67d3beccd09d267e1b61632d51f7 Mon Sep 17 00:00:00 2001
From: Ajit Pratap Singh
Date: Thu, 19 Mar 2026 21:34:08 +0530
Subject: [PATCH 04/19] feat: add risk/performance endpoints and fix frontend
trade data pipeline
- internal/api/risk.go: RiskHandler with /risk/metrics, /risk/circuit-breakers,
/risk/exposure endpoints; uses []interface{} conversion for CalculateVaR
- internal/api/performance.go: PerformanceHandler with /performance/pairs endpoint
aggregating realized PnL by symbol across active sessions
- cmd/api/routes.go: register riskHandler and perfHandler in setupRoutes
- web/dashboard/lib/api.ts: add getTrades(), getRiskMetrics(), getCircuitBreakers(),
getRiskExposure(), getPairPerformance() methods to ApiClient
- web/dashboard/hooks/useTradeData.ts: fix useTrades() to hit real API (respects
USE_MOCK_DATA), normalize response to Trade[], fix equity curve hardcode to return
empty array instead of mock data when using real API
Co-Authored-By: Claude Sonnet 4.6
---
cmd/api/routes.go | 8 ++
internal/api/performance.go | 87 ++++++++++++
internal/api/risk.go | 199 ++++++++++++++++++++++++++++
web/dashboard/hooks/useTradeData.ts | 31 ++++-
web/dashboard/lib/api.ts | 39 ++++++
5 files changed, 357 insertions(+), 7 deletions(-)
create mode 100644 internal/api/performance.go
create mode 100644 internal/api/risk.go
diff --git a/cmd/api/routes.go b/cmd/api/routes.go
index 8c8b902..8f1736f 100644
--- a/cmd/api/routes.go
+++ b/cmd/api/routes.go
@@ -264,6 +264,14 @@ func (s *APIServer) setupRoutes() {
polymarketHandler := api.NewPolymarketHandler(s.db)
polymarketHandler.RegisterRoutesWithRateLimiter(v1, s.rateLimiter.ReadMiddleware(), s.rateLimiter.OrderMiddleware())
+ // Risk metrics routes
+ riskHandler := api.NewRiskHandler(s.db)
+ riskHandler.RegisterRoutes(v1, s.rateLimiter.ReadMiddleware())
+
+ // Performance routes
+ perfHandler := api.NewPerformanceHandler(s.db)
+ perfHandler.RegisterRoutes(v1, s.rateLimiter.ReadMiddleware())
+
// Decision analytics and outcome resolution routes
decisionAnalyticsHandler := api.NewDecisionAnalyticsHandler(s.db)
decisionAnalyticsHandler.RegisterRoutes(v1, s.rateLimiter.ReadMiddleware(), api.AuthMiddleware(s.apiKeyStore, authConfig))
diff --git a/internal/api/performance.go b/internal/api/performance.go
new file mode 100644
index 0000000..5ac536e
--- /dev/null
+++ b/internal/api/performance.go
@@ -0,0 +1,87 @@
+package api
+
+import (
+ "context"
+ "math"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ajitpratap0/cryptofunk/internal/db"
+)
+
+// PerformanceHandler provides REST endpoints for per-symbol performance metrics.
+type PerformanceHandler struct {
+ db *db.DB
+}
+
+// NewPerformanceHandler creates a new PerformanceHandler backed by the given database.
+func NewPerformanceHandler(database *db.DB) *PerformanceHandler {
+ return &PerformanceHandler{db: database}
+}
+
+// RegisterRoutes mounts the /performance sub-group under the provided router group.
+func (h *PerformanceHandler) RegisterRoutes(rg *gin.RouterGroup, readMiddleware gin.HandlerFunc) {
+ g := rg.Group("/performance")
+ g.Use(readMiddleware)
+ g.GET("/pairs", h.GetPairPerformance)
+}
+
+// GetPairPerformance returns realized PnL aggregated by trading pair across all active sessions.
+// GET /api/v1/performance/pairs
+func (h *PerformanceHandler) GetPairPerformance(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ pairs, err := h.aggregatePairPerformance(ctx)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to aggregate pair performance"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"pairs": pairs, "count": len(pairs)})
+}
+
+type pairPerf struct {
+ Symbol string `json:"symbol"`
+ RealizedPnL float64 `json:"realized_pnl"`
+ TradeCount int `json:"trade_count"`
+}
+
+func (h *PerformanceHandler) aggregatePairPerformance(ctx context.Context) ([]pairPerf, error) {
+ sessions, err := h.db.ListActiveSessions(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ type agg struct {
+ pnl float64
+ count int
+ }
+ bySymbol := make(map[string]*agg)
+
+ for _, s := range sessions {
+ positions, err := h.db.GetPositionsBySession(ctx, s.ID)
+ if err != nil {
+ continue
+ }
+ for _, p := range positions {
+ if p.ExitTime != nil && p.RealizedPnL != nil {
+ if _, ok := bySymbol[p.Symbol]; !ok {
+ bySymbol[p.Symbol] = &agg{}
+ }
+ bySymbol[p.Symbol].pnl += *p.RealizedPnL
+ bySymbol[p.Symbol].count++
+ }
+ }
+ }
+
+ result := make([]pairPerf, 0, len(bySymbol))
+ for sym, a := range bySymbol {
+ result = append(result, pairPerf{
+ Symbol: sym,
+ RealizedPnL: math.Round(a.pnl*100) / 100,
+ TradeCount: a.count,
+ })
+ }
+ return result, nil
+}
diff --git a/internal/api/risk.go b/internal/api/risk.go
new file mode 100644
index 0000000..6f0f572
--- /dev/null
+++ b/internal/api/risk.go
@@ -0,0 +1,199 @@
+package api
+
+import (
+ "context"
+ "math"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/ajitpratap0/cryptofunk/internal/db"
+ "github.com/ajitpratap0/cryptofunk/internal/risk"
+)
+
+// RiskHandler provides REST endpoints for risk metrics and circuit breaker status.
+type RiskHandler struct {
+ db *db.DB
+ riskService *risk.Service
+}
+
+// NewRiskHandler creates a new RiskHandler backed by the given database.
+func NewRiskHandler(database *db.DB) *RiskHandler {
+ return &RiskHandler{
+ db: database,
+ riskService: risk.NewService(),
+ }
+}
+
+// RegisterRoutes mounts the /risk sub-group under the provided router group.
+func (h *RiskHandler) RegisterRoutes(rg *gin.RouterGroup, readMiddleware gin.HandlerFunc) {
+ r := rg.Group("/risk")
+ r.Use(readMiddleware)
+ r.GET("/metrics", h.GetMetrics)
+ r.GET("/circuit-breakers", h.GetCircuitBreakers)
+ r.GET("/exposure", h.GetExposure)
+}
+
+// GetMetrics returns VaR, CVaR, open position count, and total exposure.
+// GET /api/v1/risk/metrics
+func (h *RiskHandler) GetMetrics(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ openPositions, err := h.db.GetAllOpenPositions(ctx)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query open positions"})
+ return
+ }
+
+ openCount := len(openPositions)
+ var totalExposure float64
+ for _, p := range openPositions {
+ totalExposure += p.Quantity * p.EntryPrice
+ }
+
+ returns, err := h.collectClosedReturns(ctx)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to collect returns"})
+ return
+ }
+
+ response := gin.H{
+ "open_positions": openCount,
+ "total_exposure": math.Round(totalExposure*100) / 100,
+ "data_points": len(returns),
+ "var_95": nil,
+ "var_99": nil,
+ "expected_shortfall": nil,
+ }
+
+ if len(returns) >= 10 {
+ // CalculateVaR requires []interface{} not []float64
+ returnsIface := make([]interface{}, len(returns))
+ for i, v := range returns {
+ returnsIface[i] = v
+ }
+
+ res95, err := h.riskService.CalculateVaR(map[string]interface{}{
+ "returns": returnsIface,
+ "confidence_level": 0.95,
+ })
+ if err == nil {
+ if varResult, ok := res95.(*risk.VaRResult); ok {
+ response["var_95"] = varResult.VaR
+ }
+ }
+
+ res99, err := h.riskService.CalculateVaR(map[string]interface{}{
+ "returns": returnsIface,
+ "confidence_level": 0.99,
+ })
+ if err == nil {
+ if varResult, ok := res99.(*risk.VaRResult); ok {
+ response["var_99"] = varResult.VaR
+ response["expected_shortfall"] = varResult.CVaR
+ }
+ }
+ }
+
+ c.JSON(http.StatusOK, response)
+}
+
+// GetCircuitBreakers returns the status of system-level circuit breakers.
+// GET /api/v1/risk/circuit-breakers
+func (h *RiskHandler) GetCircuitBreakers(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ sessions, err := h.db.ListActiveSessions(ctx)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list sessions"})
+ return
+ }
+
+ var totalPnL, maxDrawdown float64
+ var totalTrades int
+ for _, s := range sessions {
+ totalPnL += s.TotalPnL
+ if s.MaxDrawdown > maxDrawdown {
+ maxDrawdown = s.MaxDrawdown
+ }
+ totalTrades += s.TotalTrades
+ }
+
+ // Max Daily Loss: triggered when cumulative losses exceed $5000
+ lossAmount := math.Abs(math.Min(totalPnL, 0))
+ breakers := []gin.H{
+ buildBreaker("Max Daily Loss", lossAmount, 5000),
+ buildBreaker("Max Drawdown %", maxDrawdown*100, 10),
+ buildBreaker("Total Trade Count", float64(totalTrades), 100),
+ }
+
+ c.JSON(http.StatusOK, gin.H{"circuit_breakers": breakers, "count": len(breakers)})
+}
+
+func buildBreaker(name string, current, threshold float64) gin.H {
+ status := "OK"
+ if current >= threshold {
+ status = "TRIGGERED"
+ } else if threshold > 0 && current/threshold >= 0.8 {
+ status = "WARNING"
+ }
+ return gin.H{
+ "name": name,
+ "current": math.Round(current*100) / 100,
+ "threshold": threshold,
+ "status": status,
+ }
+}
+
+// GetExposure returns open position exposure grouped by symbol.
+// GET /api/v1/risk/exposure
+func (h *RiskHandler) GetExposure(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ openPositions, err := h.db.GetAllOpenPositions(ctx)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query open positions"})
+ return
+ }
+
+ exposureBySymbol := make(map[string]float64)
+ for _, p := range openPositions {
+ exposureBySymbol[p.Symbol] += p.Quantity * p.EntryPrice
+ }
+
+ type symbolExposure struct {
+ Symbol string `json:"symbol"`
+ Exposure float64 `json:"exposure"`
+ }
+ result := make([]symbolExposure, 0, len(exposureBySymbol))
+ for sym, exp := range exposureBySymbol {
+ result = append(result, symbolExposure{
+ Symbol: sym,
+ Exposure: math.Round(exp*100) / 100,
+ })
+ }
+
+ c.JSON(http.StatusOK, gin.H{"exposure": result, "count": len(result)})
+}
+
+// collectClosedReturns gathers RealizedPnL from closed positions across active sessions.
+func (h *RiskHandler) collectClosedReturns(ctx context.Context) ([]float64, error) {
+ sessions, err := h.db.ListActiveSessions(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ var returns []float64
+ for _, s := range sessions {
+ positions, err := h.db.GetPositionsBySession(ctx, s.ID)
+ if err != nil {
+ continue
+ }
+ for _, p := range positions {
+ if p.ExitTime != nil && p.RealizedPnL != nil {
+ returns = append(returns, *p.RealizedPnL)
+ }
+ }
+ }
+ return returns, nil
+}
diff --git a/web/dashboard/hooks/useTradeData.ts b/web/dashboard/hooks/useTradeData.ts
index bc87aa1..723da53 100644
--- a/web/dashboard/hooks/useTradeData.ts
+++ b/web/dashboard/hooks/useTradeData.ts
@@ -18,7 +18,7 @@ import {
isRawDashboardResponse,
isRawDashboardPnlResponse,
} from '@/types/api-responses'
-import type { Trade, Position, Order, UnifiedPortfolio, DashboardStats } from '@/lib/types'
+import type { Trade, Position, Order, UnifiedPortfolio, DashboardStats, EquityPoint } from '@/lib/types'
// Query Keys
export const QUERY_KEYS = {
@@ -33,15 +33,32 @@ export const QUERY_KEYS = {
} as const
// Trades
-export function useTrades() {
+export function useTrades(limit = 50, offset = 0) {
return useQuery({
- queryKey: [...QUERY_KEYS.trades],
+ queryKey: [...QUERY_KEYS.trades, limit, offset],
queryFn: async () => {
- const mockTrades = getMockTrades()
+ if (USE_MOCK_DATA) {
+ return {
+ success: true as const,
+ data: getMockTrades(),
+ timestamp: new Date().toISOString(),
+ }
+ }
+
+ const response = await apiClient.getTrades(limit, offset)
+ if (!response.success) {
+ throw new Error(response.error || 'Failed to fetch trades')
+ }
+
+ const raw: unknown = response.data
+ const tradeList: Trade[] =
+ raw && typeof raw === 'object' && 'trades' in raw
+ ? (raw as { trades: Trade[] }).trades
+ : []
return {
success: true as const,
- data: mockTrades,
- timestamp: new Date().toISOString(),
+ data: tradeList,
+ timestamp: response.timestamp,
}
},
staleTime: REFRESH_INTERVALS.trades,
@@ -262,7 +279,7 @@ export function useDashboardPnl() {
data: {
daily: raw.realized_pnl ?? 0,
total: raw.total_pnl ?? 0,
- equity: getMockEquityPoints(), // No equity curve from API yet
+ equity: (raw as { equity_curve?: EquityPoint[] }).equity_curve ?? [],
},
timestamp: response.timestamp,
}
diff --git a/web/dashboard/lib/api.ts b/web/dashboard/lib/api.ts
index 986c3b8..105436e 100644
--- a/web/dashboard/lib/api.ts
+++ b/web/dashboard/lib/api.ts
@@ -276,6 +276,45 @@ class ApiClient {
body: JSON.stringify(trade),
})
}
+
+ // Trades (fill records)
+ async getTrades(limit = 50, offset = 0): Promise> {
+ return this.request(`/trades?limit=${limit}&offset=${offset}`)
+ }
+
+ // Risk metrics
+ async getRiskMetrics(): Promise> {
+ return this.request('/risk/metrics')
+ }
+
+ async getCircuitBreakers(): Promise
+ count: number
+ }>> {
+ return this.request('/risk/circuit-breakers')
+ }
+
+ async getRiskExposure(): Promise
+ count: number
+ }>> {
+ return this.request('/risk/exposure')
+ }
+
+ // Performance by trading pair
+ async getPairPerformance(): Promise
+ count: number
+ }>> {
+ return this.request('/performance/pairs')
+ }
}
export const apiClient = new ApiClient()
From 3cf27ea31dd17635b0ab3ea5ff5acbe3e6e4cab0 Mon Sep 17 00:00:00 2001
From: Ajit Pratap Singh
Date: Thu, 19 Mar 2026 21:38:37 +0530
Subject: [PATCH 05/19] feat(dashboard): wire risk page and performance hooks
to real API
- hooks/usePerformance.ts: fix usePairPerformance, useRiskMetrics,
useCircuitBreakers to use apiClient (proper /api/v1 prefix); add
useRiskExposure hook; remove stale RiskMetrics/CircuitBreaker imports
- app/risk/page.tsx: replace all hardcoded mock constants with real API
data via useRiskMetrics/useCircuitBreakers/useRiskExposure hooks;
map backend status strings (OK/WARNING/TRIGGERED) to component shape;
compute exposure percentages from API totals; keep drawdown/alerts as
static placeholders (no API equivalent yet)
Co-Authored-By: Claude Sonnet 4.6
---
web/dashboard/app/risk/page.tsx | 263 ++++++++++----------------
web/dashboard/hooks/usePerformance.ts | 132 +++----------
2 files changed, 126 insertions(+), 269 deletions(-)
diff --git a/web/dashboard/app/risk/page.tsx b/web/dashboard/app/risk/page.tsx
index f69cde2..5768373 100644
--- a/web/dashboard/app/risk/page.tsx
+++ b/web/dashboard/app/risk/page.tsx
@@ -1,13 +1,11 @@
'use client'
import { useState, useMemo } from 'react'
-import {
- Shield,
- AlertTriangle,
- TrendingDown,
- Activity,
+import {
+ Shield,
+ TrendingDown,
+ Activity,
RefreshCw,
- ChevronDown,
BarChart3,
Zap
} from 'lucide-react'
@@ -16,80 +14,21 @@ import { DrawdownChart } from '@/components/charts/DrawdownChart'
import { CircuitBreakerStatus } from '@/components/risk/CircuitBreakerStatus'
import { StatCard } from '@/components/ui/StatCard'
import { cn, formatCurrency, formatPercentage } from '@/lib/utils'
-import { COLORS, RISK_THRESHOLDS } from '@/lib/constants'
-import type { CircuitBreaker, RiskMetrics } from '@/lib/types'
+import { RISK_THRESHOLDS } from '@/lib/constants'
+import { useRiskMetrics, useCircuitBreakers, useRiskExposure } from '@/hooks/usePerformance'
+import type { CircuitBreaker } from '@/lib/types'
-// ── Mock Data ──────────────────────────────────────────────────────
-
-const mockRiskMetrics: RiskMetrics = {
- var95: 0.023,
- var99: 0.041,
- expectedShortfall: 0.052,
- betaToMarket: 0.87,
- correlation: {
- 'BTC/USDT': 1.0,
- 'ETH/USDT': 0.89,
- 'SOL/USDT': 0.76,
- 'XRP/USDT': 0.64,
- 'ADA/USDT': 0.58,
- },
- concentration: {
- 'BTC/USDT': 0.35,
- 'ETH/USDT': 0.25,
- 'SOL/USDT': 0.18,
- 'XRP/USDT': 0.12,
- 'ADA/USDT': 0.10,
- },
-}
-
-const mockCircuitBreakers: CircuitBreaker[] = [
- {
- name: 'Max Daily Loss',
- status: 'normal',
- threshold: 0.05,
- currentValue: 0.018,
- description: 'Maximum allowed daily portfolio loss',
- },
- {
- name: 'Max Drawdown',
- status: 'warning',
- threshold: 0.10,
- currentValue: 0.078,
- description: 'Maximum peak-to-trough drawdown',
- },
- {
- name: 'Position Concentration',
- status: 'normal',
- threshold: 0.50,
- currentValue: 0.35,
- description: 'Maximum single-asset concentration',
- },
- {
- name: 'Leverage Limit',
- status: 'normal',
- threshold: 10,
- currentValue: 3.2,
- description: 'Maximum portfolio leverage ratio',
- },
-]
-
-const mockExposure = [
- { symbol: 'BTC/USDT', exposure: 35000, percentage: 35, value: 35000, side: 'long' as const },
- { symbol: 'ETH/USDT', exposure: 25000, percentage: 25, value: 25000, side: 'long' as const },
- { symbol: 'SOL/USDT', exposure: 18000, percentage: 18, value: 18000, side: 'long' as const },
- { symbol: 'XRP/USDT', exposure: 12000, percentage: 12, value: 12000, side: 'short' as const },
- { symbol: 'ADA/USDT', exposure: 10000, percentage: 10, value: 10000, side: 'long' as const },
-]
+// ── Static Mock Data (no API equivalent yet) ───────────────────────
const mockDrawdown = Array.from({ length: 90 }, (_, i) => {
const now = Date.now()
const timestamp = new Date(now - (89 - i) * 24 * 60 * 60 * 1000).toISOString()
- const base = Math.sin(i / 15) * 4 + Math.random() * 2 // Generate percentage values (0-6%)
- const drawdownPercent = -Math.abs(base) // Negative percentage value (-6% to 0%)
+ const base = Math.sin(i / 15) * 4 + Math.random() * 2
+ const drawdownPercent = -Math.abs(base)
return {
timestamp,
drawdown: drawdownPercent,
- equity: 100000 + (drawdownPercent / 100) * 100000, // Convert percentage to equity change
+ equity: 100000 + (drawdownPercent / 100) * 100000,
}
})
@@ -106,16 +45,70 @@ const mockAlerts = [
export default function RiskPage() {
const [timeRange, setTimeRange] = useState<'1w' | '1m' | '3m'>('1m')
+ const { data: metricsResponse, refetch: refetchMetrics } = useRiskMetrics()
+ const { data: breakersResponse, refetch: refetchBreakers } = useCircuitBreakers()
+ const { data: exposureResponse, refetch: refetchExposure } = useRiskExposure()
+
+ const handleRefresh = () => {
+ refetchMetrics()
+ refetchBreakers()
+ refetchExposure()
+ }
+
+ // Map API risk metrics
+ const apiMetrics = metricsResponse?.data as {
+ var_95: number | null
+ var_99: number | null
+ expected_shortfall: number | null
+ open_positions: number
+ total_exposure: number
+ } | undefined
+
+ // Map API circuit breakers to component shape
+ const circuitBreakers: CircuitBreaker[] = useMemo(() => {
+ const raw = breakersResponse?.data as
+ | { circuit_breakers: Array<{ name: string; current: number; threshold: number; status: string }> }
+ | undefined
+ if (!raw?.circuit_breakers?.length) return []
+ return raw.circuit_breakers.map(cb => ({
+ name: cb.name,
+ status: cb.status === 'TRIGGERED' ? 'triggered' : cb.status === 'WARNING' ? 'warning' : 'normal',
+ threshold: cb.threshold,
+ currentValue: cb.current,
+ description: cb.name,
+ } as CircuitBreaker))
+ }, [breakersResponse])
+
+ // Map API exposure data
+ const exposureData = useMemo(() => {
+ const raw = exposureResponse?.data as
+ | { exposure: Array<{ symbol: string; exposure: number }> }
+ | undefined
+ if (!raw?.exposure?.length) return []
+ const total = raw.exposure.reduce((sum, e) => sum + e.exposure, 0)
+ return raw.exposure.map(e => ({
+ symbol: e.symbol,
+ exposure: e.exposure,
+ percentage: total > 0 ? Math.round((e.exposure / total) * 100) : 0,
+ value: e.exposure,
+ side: 'long' as const,
+ }))
+ }, [exposureResponse])
+
+ const var95 = apiMetrics?.var_95 ?? null
+ const var99 = apiMetrics?.var_99 ?? null
+
const riskScore = useMemo(() => {
+ if (!circuitBreakers.length) return 100
let score = 100
- mockCircuitBreakers.forEach(cb => {
- const usage = Math.abs(cb.currentValue) / Math.abs(cb.threshold)
+ circuitBreakers.forEach(cb => {
+ const usage = cb.threshold > 0 ? Math.abs(cb.currentValue) / Math.abs(cb.threshold) : 0
if (cb.status === 'triggered') score -= 30
else if (cb.status === 'warning') score -= 15
else if (usage > 0.5) score -= 5
})
return Math.max(0, Math.min(100, score))
- }, [])
+ }, [circuitBreakers])
const riskLevel = riskScore >= 80 ? 'Low' : riskScore >= 50 ? 'Medium' : 'High'
const riskColor = riskScore >= 80 ? 'text-profit' : riskScore >= 50 ? 'text-warning' : 'text-loss'
@@ -130,7 +123,10 @@ export default function RiskPage() {
Portfolio risk monitoring & circuit breaker status
-