feat: add API endpoints for positions, close, close-all, pnl, plus DB persistence before goroutine
This commit is contained in:
+112
@@ -8,6 +8,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -242,6 +243,10 @@ func (d *Dashboard) Run() {
|
|||||||
mux.HandleFunc("GET /events", d.handleSSE)
|
mux.HandleFunc("GET /events", d.handleSSE)
|
||||||
mux.HandleFunc("POST /api/stop", d.handleStop)
|
mux.HandleFunc("POST /api/stop", d.handleStop)
|
||||||
mux.HandleFunc("POST /api/start", d.handleStart)
|
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{
|
server := &http.Server{
|
||||||
Addr: d.addr,
|
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"})
|
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{}) {
|
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
|
|||||||
@@ -337,6 +337,51 @@ func (t *Trader) Start() {
|
|||||||
log.Println("[Trader] ▶ Trading RESUMED")
|
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.
|
// Tick is called every scanner cycle — checks scaling and exit.
|
||||||
func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
|
func (t *Trader) Tick(store *PriceStore, notifier *Notifier) {
|
||||||
if !t.IsConfigured() {
|
if !t.IsConfigured() {
|
||||||
|
|||||||
Reference in New Issue
Block a user