From 88462f98290dd7dae047378f3fcd44f27c24af7f Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Mon, 4 May 2026 20:28:52 +0800 Subject: [PATCH] feat: add API endpoints for positions, close, close-all, pnl, plus DB persistence before goroutine --- dashboard.go | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++ trader.go | 45 +++++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/dashboard.go b/dashboard.go index b259cf5..15e89ac 100644 --- a/dashboard.go +++ b/dashboard.go @@ -8,6 +8,7 @@ import ( "math" "net/http" "os" + "strings" "sync" "time" @@ -242,6 +243,10 @@ func (d *Dashboard) Run() { mux.HandleFunc("GET /events", d.handleSSE) mux.HandleFunc("POST /api/stop", d.handleStop) mux.HandleFunc("POST /api/start", d.handleStart) + mux.HandleFunc("GET /api/positions", d.handlePositions) + mux.HandleFunc("GET /api/pnl", d.handlePnL) + mux.HandleFunc("POST /api/close/", d.handleClosePosition) + mux.HandleFunc("POST /api/close-all", d.handleCloseAll) server := &http.Server{ Addr: d.addr, @@ -753,6 +758,113 @@ func (d *Dashboard) handleStart(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"status": "started", "message": "Trading resumed"}) } +func (d *Dashboard) handlePositions(w http.ResponseWriter, r *http.Request) { + positions := d.trader.ReadSnapshot() + posList := make([]map[string]interface{}, 0, len(positions)) + for _, pos := range positions { + entry := map[string]interface{}{ + "id": pos.DBTradeID, + "coin": pos.Coin, + "direction": pos.Direction, + "amount_usd": pos.AmountUSD, + "entry_spread": pos.EntrySpread, + "scales": pos.ScaleLevels, + "duration": time.Since(pos.StartedAt).Round(time.Second).String(), + "started_at": pos.StartedAt.Format("15:04:05"), + "status": pos.Status, + "long_exchange": pos.LongLeg.Exchange, + "short_exchange": pos.ShortLeg.Exchange, + "long_entry": pos.LongLeg.EntryPrice, + "short_entry": pos.ShortLeg.EntryPrice, + "long_entry_prices": pos.LongEntryPrices, + "short_entry_prices": pos.ShortEntryPrices, + } + // Live PnL from current prices + snap := d.store.GetAll() + if exMap := snap[pos.Coin]; exMap != nil { + bgP := exMap[ExBitget] + hlP := exMap[ExHyperLiquid] + if bgP > 0 && hlP > 0 { + var longCurrent, shortCurrent float64 + if pos.LongLeg.Exchange == ExBitget { + longCurrent, shortCurrent = bgP, hlP + } else { + longCurrent, shortCurrent = hlP, bgP + } + longAvg := weightedAvgPrice(pos.LongEntryPrices, pos.AmountUSD) + shortAvg := weightedAvgPrice(pos.ShortEntryPrices, pos.AmountUSD) + longPnl := (longCurrent - longAvg) / longAvg * 100 + shortPnl := (shortAvg - shortCurrent) / shortAvg * 100 + feeEntryUSD := pos.AmountUSD * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) / 100 + feeExitUSD := pos.AmountUSD * (takerFees[ExBitget] + takerFees[ExHyperLiquid]) / 100 + pricePnLUSD := pos.AmountUSD * (longPnl + shortPnl) / 100 + netPnLUSD := pricePnLUSD - feeEntryUSD - feeExitUSD + entry["pnl_pct"] = math.Round((longPnl+shortPnl)*10000) / 10000 + entry["pnl_usd"] = math.Round(netPnLUSD*100) / 100 + entry["long_pnl_pct"] = math.Round(longPnl*10000) / 10000 + entry["short_pnl_pct"] = math.Round(shortPnl*10000) / 10000 + entry["long_current"] = longCurrent + entry["short_current"] = shortCurrent + } + } + posList = append(posList, entry) + } + writeJSON(w, map[string]interface{}{ + "positions": posList, + "count": len(posList), + }) +} + +func (d *Dashboard) handleClosePosition(w http.ResponseWriter, r *http.Request) { + // POST /api/close/{coin} + coin := strings.TrimPrefix(r.URL.Path, "/api/close/") + if coin == "" || coin == r.URL.Path { + http.Error(w, "Missing coin name", 400) + return + } + if err := d.trader.ClosePosition(coin); err != nil { + http.Error(w, err.Error(), 400) + return + } + writeJSON(w, map[string]string{"status": "closed", "coin": coin, "message": "Position closed"}) +} + +func (d *Dashboard) handleCloseAll(w http.ResponseWriter, r *http.Request) { + count := d.trader.CloseAllPositions() + writeJSON(w, map[string]interface{}{ + "status": "closed", + "count": count, + "message": fmt.Sprintf("Closed %d positions", count), + }) +} + +func (d *Dashboard) handlePnL(w http.ResponseWriter, r *http.Request) { + converged, diverged, flat, total := d.trader.GetClosedStats() + trades := d.trader.GetClosedTrades() + detail := calcDetailedStats(trades, d.trader.cfg.InitialCapital) + positions := d.trader.ReadSnapshot() + writeJSON(w, map[string]interface{}{ + "total_trades": total, + "converged": converged, + "diverged": diverged, + "flat": flat, + "open_positions": len(positions), + "capital": d.trader.cfg.InitialCapital, + "detail": map[string]interface{}{ + "total_pnl_usd": math.Round(detail.TotalPnlUSD*100) / 100, + "capital_pnl": math.Round(detail.CapitalPnlPct*10000) / 10000, + "avg_pnl": detail.AvgPnlPct, + "max_profit": detail.MaxProfitPct, + "max_loss": detail.MaxLossPct, + "avg_dur": detail.AvgDuration, + "win_rate": detail.WinRate, + "wins": detail.WinningTrades, + "losses": detail.LosingTrades, + "total_dur": detail.TotalDuration, + }, + }) +} + func writeJSON(w http.ResponseWriter, v interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) diff --git a/trader.go b/trader.go index 151e261..3202d4a 100644 --- a/trader.go +++ b/trader.go @@ -337,6 +337,51 @@ func (t *Trader) Start() { log.Println("[Trader] ▶ Trading RESUMED") } +// ClosePosition closes a single position by coin name. +func (t *Trader) ClosePosition(coin string) error { + t.mu.Lock() + pos, ok := t.positions[coin] + t.mu.Unlock() + if !ok { + return fmt.Errorf("no open position for %s", coin) + } + if pos.Status != "open" && pos.Status != "close_failed" { + return fmt.Errorf("position %s is in status %s, cannot close", coin, pos.Status) + } + t.closeBothLegs(pos) + pos.Status = "closed" + pos.ExitedAt = time.Now() + t.mu.Lock() + delete(t.positions, coin) + t.mu.Unlock() + log.Printf("[Trader] Manually closed %s %s", coin, pos.Direction) + return nil +} + +// CloseAllPositions closes every open position. +func (t *Trader) CloseAllPositions() int { + t.mu.Lock() + positions := make([]*ArbPosition, 0, len(t.positions)) + for _, pos := range t.positions { + positions = append(positions, pos) + } + t.mu.Unlock() + count := 0 + for _, pos := range positions { + if pos.Status == "open" || pos.Status == "close_failed" { + t.closeBothLegs(pos) + pos.Status = "closed" + pos.ExitedAt = time.Now() + t.mu.Lock() + delete(t.positions, pos.Coin) + t.mu.Unlock() + log.Printf("[Trader] Force-closed %s %s", pos.Coin, pos.Direction) + count++ + } + } + return count +} + // Tick is called every scanner cycle — checks scaling and exit. func (t *Trader) Tick(store *PriceStore, notifier *Notifier) { if !t.IsConfigured() {