From 3f084005e4051cdc15cade13429f45b2ac024c78 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Mon, 15 Dec 2025 21:22:22 +0800 Subject: [PATCH] feat: upgrade Binance to Algo Order API and improve trading flow - Upgrade go-binance to v2.8.9 with new Algo Order API - Migrate SetStopLoss/SetTakeProfit to use AlgoOrderTypeStopMarket/TakeProfitMarket - Update cancel functions to handle both legacy and Algo orders - Fix Lighter stop orders using correct order types (type=2/4) with TriggerPrice - Add CancelAllOrders before opening positions for Bybit and Lighter - Fix decision limit selector in API handler - Add stop_loss/take_profit/confidence fields to DecisionAction - Store decisions array in database with proper serialization - Redesign DecisionCard with beautiful entry/SL/TP display --- api/server.go | 16 +- go.mod | 2 +- go.sum | 2 + store/decision.go | 47 +-- trader/auto_trader.go | 28 +- trader/binance_futures.go | 274 +++++++++++------- trader/bybit_trader.go | 18 ++ trader/lighter_trader_v2_orders.go | 22 +- trader/lighter_trader_v2_trading.go | 123 +++++++- web/src/components/DecisionCard.tsx | 426 ++++++++++++++++++++-------- web/src/i18n/translations.ts | 6 + web/src/types.ts | 5 +- 12 files changed, 699 insertions(+), 270 deletions(-) diff --git a/api/server.go b/api/server.go index 988a2a9e..ff9e532d 100644 --- a/api/server.go +++ b/api/server.go @@ -14,6 +14,7 @@ import ( "nofx/manager" "nofx/store" "nofx/trader" + "strconv" "strings" "time" @@ -1898,7 +1899,7 @@ func (s *Server) handleDecisions(c *gin.Context) { c.JSON(http.StatusOK, records) } -// handleLatestDecisions Latest decision logs (most recent 5, newest first) +// handleLatestDecisions Latest decision logs (newest first, supports limit parameter) func (s *Server) handleLatestDecisions(c *gin.Context) { _, traderID, err := s.getTraderFromQuery(c) if err != nil { @@ -1912,7 +1913,18 @@ func (s *Server) handleLatestDecisions(c *gin.Context) { return } - records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 5) + // Get limit from query parameter, default to 5 + limit := 5 + if limitStr := c.Query("limit"); limitStr != "" { + if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { + limit = parsedLimit + if limit > 100 { + limit = 100 // Max 100 to prevent abuse + } + } + } + + records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": fmt.Sprintf("Failed to get decision log: %v", err), diff --git a/go.mod b/go.mod index 811edcfa..fdac655a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module nofx go 1.25.0 require ( - github.com/adshao/go-binance/v2 v2.8.7 + github.com/adshao/go-binance/v2 v2.8.9 github.com/agiledragon/gomonkey/v2 v2.13.0 github.com/ethereum/go-ethereum v1.16.5 github.com/gin-gonic/gin v1.11.0 diff --git a/go.sum b/go.sum index 676610b6..e9842c4a 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDO github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= github.com/adshao/go-binance/v2 v2.8.7 h1:n7jkhwIHMdtd/9ZU2gTqFV15XVSbUCjyFlOUAtTd8uU= github.com/adshao/go-binance/v2 v2.8.7/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM= +github.com/adshao/go-binance/v2 v2.8.9 h1:NX+4u/LgEmrjTS7OMWU+9ZgfHKFM61RPhnr9/SqWPhc= +github.com/adshao/go-binance/v2 v2.8.9/go.mod h1:XkkuecSyJKPolaCGf/q4ovJYB3t0P+7RUYTbGr+LMGM= github.com/agiledragon/gomonkey/v2 v2.13.0 h1:B24Jg6wBI1iB8EFR1c+/aoTg7QN/Cum7YffG8KMIyYo= github.com/agiledragon/gomonkey/v2 v2.13.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= diff --git a/store/decision.go b/store/decision.go index 917f53e5..7c96681f 100644 --- a/store/decision.go +++ b/store/decision.go @@ -56,16 +56,20 @@ type PositionSnapshot struct { } // DecisionAction decision action -type DecisionAction struct{ - Action string `json:"action"` - Symbol string `json:"symbol"` - Quantity float64 `json:"quantity"` - Leverage int `json:"leverage"` - Price float64 `json:"price"` - OrderID int64 `json:"order_id"` - Timestamp time.Time `json:"timestamp"` - Success bool `json:"success"` - Error string `json:"error"` +type DecisionAction struct { + Action string `json:"action"` + Symbol string `json:"symbol"` + Quantity float64 `json:"quantity"` + Leverage int `json:"leverage"` + Price float64 `json:"price"` + StopLoss float64 `json:"stop_loss,omitempty"` // Stop loss price + TakeProfit float64 `json:"take_profit,omitempty"` // Take profit price + Confidence int `json:"confidence,omitempty"` // AI confidence (0-100) + Reasoning string `json:"reasoning,omitempty"` // Brief reasoning + OrderID int64 `json:"order_id"` + Timestamp time.Time `json:"timestamp"` + Success bool `json:"success"` + Error string `json:"error"` } // Statistics statistics information @@ -113,6 +117,9 @@ func (s *DecisionStore) initTables() error { // Migration: add raw_response column if not exists s.db.Exec(`ALTER TABLE decision_records ADD COLUMN raw_response TEXT DEFAULT ''`) + // Migration: add decisions column if not exists + s.db.Exec(`ALTER TABLE decision_records ADD COLUMN decisions TEXT DEFAULT '[]'`) + return nil } @@ -124,22 +131,23 @@ func (s *DecisionStore) LogDecision(record *DecisionRecord) error { record.Timestamp = record.Timestamp.UTC() } - // Serialize candidate coins and execution log to JSON + // Serialize candidate coins, execution log and decisions to JSON candidateCoinsJSON, _ := json.Marshal(record.CandidateCoins) executionLogJSON, _ := json.Marshal(record.ExecutionLog) + decisionsJSON, _ := json.Marshal(record.Decisions) // Insert decision record main table (only save AI decision related content) result, err := s.db.Exec(` INSERT INTO decision_records ( trader_id, cycle_number, timestamp, system_prompt, input_prompt, cot_trace, decision_json, raw_response, candidate_coins, execution_log, - success, error_message, ai_request_duration_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + decisions, success, error_message, ai_request_duration_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, record.TraderID, record.CycleNumber, record.Timestamp.Format(time.RFC3339), record.SystemPrompt, record.InputPrompt, record.CoTTrace, record.DecisionJSON, record.RawResponse, string(candidateCoinsJSON), string(executionLogJSON), - record.Success, record.ErrorMessage, record.AIRequestDurationMs, + string(decisionsJSON), record.Success, record.ErrorMessage, record.AIRequestDurationMs, ) if err != nil { return fmt.Errorf("failed to insert decision record: %w", err) @@ -159,7 +167,7 @@ func (s *DecisionStore) GetLatestRecords(traderID string, n int) ([]*DecisionRec rows, err := s.db.Query(` SELECT id, trader_id, cycle_number, timestamp, system_prompt, input_prompt, cot_trace, decision_json, candidate_coins, execution_log, - success, error_message, ai_request_duration_ms + COALESCE(decisions, '[]'), success, error_message, ai_request_duration_ms FROM decision_records WHERE trader_id = ? ORDER BY timestamp DESC @@ -197,7 +205,7 @@ func (s *DecisionStore) GetAllLatestRecords(n int) ([]*DecisionRecord, error) { rows, err := s.db.Query(` SELECT id, trader_id, cycle_number, timestamp, system_prompt, input_prompt, cot_trace, decision_json, candidate_coins, execution_log, - success, error_message, ai_request_duration_ms + COALESCE(decisions, '[]'), success, error_message, ai_request_duration_ms FROM decision_records ORDER BY timestamp DESC LIMIT ? @@ -231,7 +239,7 @@ func (s *DecisionStore) GetRecordsByDate(traderID string, date time.Time) ([]*De rows, err := s.db.Query(` SELECT id, trader_id, cycle_number, timestamp, system_prompt, input_prompt, cot_trace, decision_json, candidate_coins, execution_log, - success, error_message, ai_request_duration_ms + COALESCE(decisions, '[]'), success, error_message, ai_request_duration_ms FROM decision_records WHERE trader_id = ? AND DATE(timestamp) = ? ORDER BY timestamp ASC @@ -338,13 +346,13 @@ func (s *DecisionStore) GetLastCycleNumber(traderID string) (int, error) { func (s *DecisionStore) scanDecisionRecord(rows *sql.Rows) (*DecisionRecord, error) { var record DecisionRecord var timestampStr string - var candidateCoinsJSON, executionLogJSON string + var candidateCoinsJSON, executionLogJSON, decisionsJSON string err := rows.Scan( &record.ID, &record.TraderID, &record.CycleNumber, ×tampStr, &record.SystemPrompt, &record.InputPrompt, &record.CoTTrace, &record.DecisionJSON, &candidateCoinsJSON, &executionLogJSON, - &record.Success, &record.ErrorMessage, &record.AIRequestDurationMs, + &decisionsJSON, &record.Success, &record.ErrorMessage, &record.AIRequestDurationMs, ) if err != nil { return nil, err @@ -353,6 +361,7 @@ func (s *DecisionStore) scanDecisionRecord(rows *sql.Rows) (*DecisionRecord, err record.Timestamp, _ = time.Parse(time.RFC3339, timestampStr) json.Unmarshal([]byte(candidateCoinsJSON), &record.CandidateCoins) json.Unmarshal([]byte(executionLogJSON), &record.ExecutionLog) + json.Unmarshal([]byte(decisionsJSON), &record.Decisions) return &record, nil } diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 0c35733a..9e237850 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -528,13 +528,17 @@ func (at *AutoTrader) runCycle() error { // Execute decisions and record results for _, d := range sortedDecisions { actionRecord := store.DecisionAction{ - Action: d.Action, - Symbol: d.Symbol, - Quantity: 0, - Leverage: d.Leverage, - Price: 0, - Timestamp: time.Now(), - Success: false, + Action: d.Action, + Symbol: d.Symbol, + Quantity: 0, + Leverage: d.Leverage, + Price: 0, + StopLoss: d.StopLoss, + TakeProfit: d.TakeProfit, + Confidence: d.Confidence, + Reasoning: d.Reasoning, + Timestamp: time.Now(), + Success: false, } if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil { @@ -816,9 +820,13 @@ func (at *AutoTrader) ExecuteDecision(d *decision.Decision) error { // Create a minimal action record for tracking actionRecord := &store.DecisionAction{ - Symbol: d.Symbol, - Action: d.Action, - Leverage: d.Leverage, + Symbol: d.Symbol, + Action: d.Action, + Leverage: d.Leverage, + StopLoss: d.StopLoss, + TakeProfit: d.TakeProfit, + Confidence: d.Confidence, + Reasoning: d.Reasoning, } // Execute the decision diff --git a/trader/binance_futures.go b/trader/binance_futures.go index 68561315..6fdc883c 100644 --- a/trader/binance_futures.go +++ b/trader/binance_futures.go @@ -534,38 +534,64 @@ func (t *FuturesTrader) CloseShort(symbol string, quantity float64) (map[string] } // CancelStopLossOrders cancels only stop-loss orders (doesn't affect take-profit orders) +// Now uses both legacy API and new Algo Order API func (t *FuturesTrader) CancelStopLossOrders(symbol string) error { - // Get all open orders for this symbol + canceledCount := 0 + var cancelErrors []error + + // 1. Cancel legacy stop-loss orders orders, err := t.client.NewListOpenOrdersService(). Symbol(symbol). Do(context.Background()) - if err != nil { - return fmt.Errorf("failed to get open orders: %w", err) + if err == nil { + for _, order := range orders { + orderType := string(order.Type) + + // Only cancel stop-loss orders (don't cancel take-profit orders) + // Use string comparison since OrderType constants were removed in v2.8.9 + if orderType == "STOP_MARKET" || orderType == "STOP" { + _, err := t.client.NewCancelOrderService(). + Symbol(symbol). + OrderID(order.OrderID). + Do(context.Background()) + + if err != nil { + errMsg := fmt.Sprintf("Order ID %d: %v", order.OrderID, err) + cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) + logger.Infof(" ⚠ Failed to cancel legacy stop-loss order: %s", errMsg) + continue + } + + canceledCount++ + logger.Infof(" ✓ Canceled legacy stop-loss order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide) + } + } } - // Filter out stop-loss orders and cancel them (cancel all directions including LONG and SHORT) - canceledCount := 0 - var cancelErrors []error - for _, order := range orders { - orderType := order.Type + // 2. Cancel Algo stop-loss orders + algoOrders, err := t.client.NewListOpenAlgoOrdersService(). + Symbol(symbol). + Do(context.Background()) - // Only cancel stop-loss orders (don't cancel take-profit orders) - if orderType == futures.OrderTypeStopMarket || orderType == futures.OrderTypeStop { - _, err := t.client.NewCancelOrderService(). - Symbol(symbol). - OrderID(order.OrderID). - Do(context.Background()) + if err == nil { + for _, algoOrder := range algoOrders { + // Only cancel stop-loss orders + if algoOrder.OrderType == futures.AlgoOrderTypeStopMarket || algoOrder.OrderType == futures.AlgoOrderTypeStop { + _, err := t.client.NewCancelAlgoOrderService(). + AlgoID(algoOrder.AlgoId). + Do(context.Background()) - if err != nil { - errMsg := fmt.Sprintf("Order ID %d: %v", order.OrderID, err) - cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) - logger.Infof(" ⚠ Failed to cancel stop-loss order: %s", errMsg) - continue + if err != nil { + errMsg := fmt.Sprintf("Algo ID %d: %v", algoOrder.AlgoId, err) + cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) + logger.Infof(" ⚠ Failed to cancel Algo stop-loss order: %s", errMsg) + continue + } + + canceledCount++ + logger.Infof(" ✓ Canceled Algo stop-loss order (Algo ID: %d, Type: %s)", algoOrder.AlgoId, algoOrder.OrderType) } - - canceledCount++ - logger.Infof(" ✓ Canceled stop-loss order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide) } } @@ -584,38 +610,64 @@ func (t *FuturesTrader) CancelStopLossOrders(symbol string) error { } // CancelTakeProfitOrders cancels only take-profit orders (doesn't affect stop-loss orders) +// Now uses both legacy API and new Algo Order API func (t *FuturesTrader) CancelTakeProfitOrders(symbol string) error { - // Get all open orders for this symbol + canceledCount := 0 + var cancelErrors []error + + // 1. Cancel legacy take-profit orders orders, err := t.client.NewListOpenOrdersService(). Symbol(symbol). Do(context.Background()) - if err != nil { - return fmt.Errorf("failed to get open orders: %w", err) + if err == nil { + for _, order := range orders { + orderType := string(order.Type) + + // Only cancel take-profit orders (don't cancel stop-loss orders) + // Use string comparison since OrderType constants were removed in v2.8.9 + if orderType == "TAKE_PROFIT_MARKET" || orderType == "TAKE_PROFIT" { + _, err := t.client.NewCancelOrderService(). + Symbol(symbol). + OrderID(order.OrderID). + Do(context.Background()) + + if err != nil { + errMsg := fmt.Sprintf("Order ID %d: %v", order.OrderID, err) + cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) + logger.Infof(" ⚠ Failed to cancel legacy take-profit order: %s", errMsg) + continue + } + + canceledCount++ + logger.Infof(" ✓ Canceled legacy take-profit order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide) + } + } } - // Filter out take-profit orders and cancel them (cancel all directions including LONG and SHORT) - canceledCount := 0 - var cancelErrors []error - for _, order := range orders { - orderType := order.Type + // 2. Cancel Algo take-profit orders + algoOrders, err := t.client.NewListOpenAlgoOrdersService(). + Symbol(symbol). + Do(context.Background()) - // Only cancel take-profit orders (don't cancel stop-loss orders) - if orderType == futures.OrderTypeTakeProfitMarket || orderType == futures.OrderTypeTakeProfit { - _, err := t.client.NewCancelOrderService(). - Symbol(symbol). - OrderID(order.OrderID). - Do(context.Background()) + if err == nil { + for _, algoOrder := range algoOrders { + // Only cancel take-profit orders + if algoOrder.OrderType == futures.AlgoOrderTypeTakeProfitMarket || algoOrder.OrderType == futures.AlgoOrderTypeTakeProfit { + _, err := t.client.NewCancelAlgoOrderService(). + AlgoID(algoOrder.AlgoId). + Do(context.Background()) - if err != nil { - errMsg := fmt.Sprintf("Order ID %d: %v", order.OrderID, err) - cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) - logger.Infof(" ⚠ Failed to cancel take-profit order: %s", errMsg) - continue + if err != nil { + errMsg := fmt.Sprintf("Algo ID %d: %v", algoOrder.AlgoId, err) + cancelErrors = append(cancelErrors, fmt.Errorf("%s", errMsg)) + logger.Infof(" ⚠ Failed to cancel Algo take-profit order: %s", errMsg) + continue + } + + canceledCount++ + logger.Infof(" ✓ Canceled Algo take-profit order (Algo ID: %d, Type: %s)", algoOrder.AlgoId, algoOrder.OrderType) } - - canceledCount++ - logger.Infof(" ✓ Canceled take-profit order (Order ID: %d, Type: %s, Side: %s)", order.OrderID, orderType, order.PositionSide) } } @@ -634,61 +686,91 @@ func (t *FuturesTrader) CancelTakeProfitOrders(symbol string) error { } // CancelAllOrders cancels all pending orders for this symbol +// Now uses both legacy API and new Algo Order API func (t *FuturesTrader) CancelAllOrders(symbol string) error { + // 1. Cancel all legacy orders err := t.client.NewCancelAllOpenOrdersService(). Symbol(symbol). Do(context.Background()) if err != nil { - return fmt.Errorf("failed to cancel pending orders: %w", err) + logger.Infof(" ⚠ Failed to cancel legacy orders: %v", err) + } else { + logger.Infof(" ✓ Canceled all legacy pending orders for %s", symbol) } - logger.Infof(" ✓ Canceled all pending orders for %s", symbol) - return nil -} - -// CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions) -func (t *FuturesTrader) CancelStopOrders(symbol string) error { - // Get all open orders for this symbol - orders, err := t.client.NewListOpenOrdersService(). + // 2. Cancel all Algo orders + err = t.client.NewCancelAllAlgoOpenOrdersService(). Symbol(symbol). Do(context.Background()) if err != nil { - return fmt.Errorf("failed to get open orders: %w", err) + // Ignore "no algo orders" error + if !contains(err.Error(), "no algo") && !contains(err.Error(), "No algo") { + logger.Infof(" ⚠ Failed to cancel Algo orders: %v", err) + } + } else { + logger.Infof(" ✓ Canceled all Algo orders for %s", symbol) } - // Filter out take-profit and stop-loss orders and cancel them + return nil +} + +// CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions) +// Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system) +func (t *FuturesTrader) CancelStopOrders(symbol string) error { canceledCount := 0 - for _, order := range orders { - orderType := order.Type - // Only cancel stop-loss and take-profit orders - if orderType == futures.OrderTypeStopMarket || - orderType == futures.OrderTypeTakeProfitMarket || - orderType == futures.OrderTypeStop || - orderType == futures.OrderTypeTakeProfit { + // 1. Cancel legacy stop orders (for backward compatibility) + orders, err := t.client.NewListOpenOrdersService(). + Symbol(symbol). + Do(context.Background()) - _, err := t.client.NewCancelOrderService(). - Symbol(symbol). - OrderID(order.OrderID). - Do(context.Background()) + if err == nil { + for _, order := range orders { + orderType := string(order.Type) - if err != nil { - logger.Infof(" ⚠ Failed to cancel order %d: %v", order.OrderID, err) - continue + // Only cancel stop-loss and take-profit orders + // Use string comparison since OrderType constants were removed in v2.8.9 + if orderType == "STOP_MARKET" || + orderType == "TAKE_PROFIT_MARKET" || + orderType == "STOP" || + orderType == "TAKE_PROFIT" { + + _, err := t.client.NewCancelOrderService(). + Symbol(symbol). + OrderID(order.OrderID). + Do(context.Background()) + + if err != nil { + logger.Infof(" ⚠ Failed to cancel legacy order %d: %v", order.OrderID, err) + continue + } + + canceledCount++ + logger.Infof(" ✓ Canceled legacy stop order for %s (Order ID: %d, Type: %s)", + symbol, order.OrderID, orderType) } - - canceledCount++ - logger.Infof(" ✓ Canceled take-profit/stop-loss order for %s (Order ID: %d, Type: %s)", - symbol, order.OrderID, orderType) } } + // 2. Cancel Algo orders (new API) + err = t.client.NewCancelAllAlgoOpenOrdersService(). + Symbol(symbol). + Do(context.Background()) + + if err != nil { + // Ignore "no algo orders" error + if !contains(err.Error(), "no algo") && !contains(err.Error(), "No algo") { + logger.Infof(" ⚠ Failed to cancel Algo orders: %v", err) + } + } else { + logger.Infof(" ✓ Canceled all Algo orders for %s", symbol) + canceledCount++ + } + if canceledCount == 0 { logger.Infof(" ℹ %s has no take-profit/stop-loss orders to cancel", symbol) - } else { - logger.Infof(" ✓ Canceled %d take-profit/stop-loss order(s) for %s", canceledCount, symbol) } return nil @@ -721,7 +803,8 @@ func (t *FuturesTrader) CalculatePositionSize(balance, riskPercent, price float6 return quantity } -// SetStopLoss sets stop-loss order +// SetStopLoss sets stop-loss order using new Algo Order API +// Binance has migrated stop orders to Algo Order system (error -4120 STOP_ORDER_SWITCH_ALGO) func (t *FuturesTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { var side futures.SideType var posSide futures.PositionSideType @@ -734,33 +817,28 @@ func (t *FuturesTrader) SetStopLoss(symbol string, positionSide string, quantity posSide = futures.PositionSideTypeShort } - // Format quantity - quantityStr, err := t.FormatQuantity(symbol, quantity) - if err != nil { - return err - } - - _, err = t.client.NewCreateOrderService(). + // Use new Algo Order API + _, err := t.client.NewCreateAlgoOrderService(). Symbol(symbol). Side(side). PositionSide(posSide). - Type(futures.OrderTypeStopMarket). - StopPrice(fmt.Sprintf("%.8f", stopPrice)). - Quantity(quantityStr). + Type(futures.AlgoOrderTypeStopMarket). + TriggerPrice(fmt.Sprintf("%.8f", stopPrice)). WorkingType(futures.WorkingTypeContractPrice). ClosePosition(true). - NewClientOrderID(getBrOrderID()). + ClientAlgoId(getBrOrderID()). Do(context.Background()) if err != nil { return fmt.Errorf("failed to set stop-loss: %w", err) } - logger.Infof(" Stop-loss price set: %.4f", stopPrice) + logger.Infof(" Stop-loss price set (Algo Order): %.4f", stopPrice) return nil } -// SetTakeProfit sets take-profit order +// SetTakeProfit sets take-profit order using new Algo Order API +// Binance has migrated stop orders to Algo Order system (error -4120 STOP_ORDER_SWITCH_ALGO) func (t *FuturesTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { var side futures.SideType var posSide futures.PositionSideType @@ -773,29 +851,23 @@ func (t *FuturesTrader) SetTakeProfit(symbol string, positionSide string, quanti posSide = futures.PositionSideTypeShort } - // Format quantity - quantityStr, err := t.FormatQuantity(symbol, quantity) - if err != nil { - return err - } - - _, err = t.client.NewCreateOrderService(). + // Use new Algo Order API + _, err := t.client.NewCreateAlgoOrderService(). Symbol(symbol). Side(side). PositionSide(posSide). - Type(futures.OrderTypeTakeProfitMarket). - StopPrice(fmt.Sprintf("%.8f", takeProfitPrice)). - Quantity(quantityStr). + Type(futures.AlgoOrderTypeTakeProfitMarket). + TriggerPrice(fmt.Sprintf("%.8f", takeProfitPrice)). WorkingType(futures.WorkingTypeContractPrice). ClosePosition(true). - NewClientOrderID(getBrOrderID()). + ClientAlgoId(getBrOrderID()). Do(context.Background()) if err != nil { return fmt.Errorf("failed to set take-profit: %w", err) } - logger.Infof(" Take-profit price set: %.4f", takeProfitPrice) + logger.Infof(" Take-profit price set (Algo Order): %.4f", takeProfitPrice) return nil } diff --git a/trader/bybit_trader.go b/trader/bybit_trader.go index 6f869de8..540a1518 100644 --- a/trader/bybit_trader.go +++ b/trader/bybit_trader.go @@ -280,6 +280,15 @@ func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) { func (t *BybitTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { logger.Infof("[Bybit] ===== OpenLong called: symbol=%s, qty=%.6f, leverage=%d =====", symbol, quantity, leverage) + // First cancel all pending orders for this symbol (clean up old orders) + if err := t.CancelAllOrders(symbol); err != nil { + logger.Infof("⚠️ [Bybit] Failed to cancel old pending orders: %v", err) + } + // Also cancel conditional orders (stop-loss/take-profit) - Bybit keeps them separate + if err := t.CancelStopOrders(symbol); err != nil { + logger.Infof("⚠️ [Bybit] Failed to cancel old stop orders: %v", err) + } + // Set leverage first if err := t.SetLeverage(symbol, leverage); err != nil { logger.Infof("⚠️ [Bybit] Failed to set leverage: %v", err) @@ -314,6 +323,15 @@ func (t *BybitTrader) OpenLong(symbol string, quantity float64, leverage int) (m func (t *BybitTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { logger.Infof("[Bybit] ===== OpenShort called: symbol=%s, qty=%.6f, leverage=%d =====", symbol, quantity, leverage) + // First cancel all pending orders for this symbol (clean up old orders) + if err := t.CancelAllOrders(symbol); err != nil { + logger.Infof("⚠️ [Bybit] Failed to cancel old pending orders: %v", err) + } + // Also cancel conditional orders (stop-loss/take-profit) - Bybit keeps them separate + if err := t.CancelStopOrders(symbol); err != nil { + logger.Infof("⚠️ [Bybit] Failed to cancel old stop orders: %v", err) + } + // Set leverage first if err := t.SetLeverage(symbol, leverage); err != nil { logger.Infof("⚠️ [Bybit] Failed to set leverage: %v", err) diff --git a/trader/lighter_trader_v2_orders.go b/trader/lighter_trader_v2_orders.go index 9e3e2cc9..9b14fcbe 100644 --- a/trader/lighter_trader_v2_orders.go +++ b/trader/lighter_trader_v2_orders.go @@ -14,44 +14,46 @@ import ( ) // SetStopLoss Set stop-loss order (implements Trader interface) +// IMPORTANT: Uses StopLossOrder type (type=2) with TriggerPrice, NOT regular limit order func (t *LighterTraderV2) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error { if t.txClient == nil { return fmt.Errorf("TxClient not initialized") } - logger.Infof("🛑 LIGHTER Setting stop-loss: %s %s qty=%.4f, stop=%.2f", symbol, positionSide, quantity, stopPrice) + logger.Infof("🛑 LIGHTER Setting stop-loss: %s %s qty=%.4f, trigger=%.2f", symbol, positionSide, quantity, stopPrice) - // Determine order direction (short position uses buy order, long position uses sell order) + // Determine order direction (long position uses sell order, short position uses buy order) isAsk := (positionSide == "LONG" || positionSide == "long") - // Create limit stop-loss order - _, err := t.CreateOrder(symbol, isAsk, quantity, stopPrice, "limit") + // Create stop-loss order with TriggerPrice (type=2: StopLossOrder) + _, err := t.CreateStopOrder(symbol, isAsk, quantity, stopPrice, "stop_loss") if err != nil { return fmt.Errorf("failed to set stop-loss: %w", err) } - logger.Infof("✓ LIGHTER stop-loss set: %.2f", stopPrice) + logger.Infof("✓ LIGHTER stop-loss set: trigger=%.2f", stopPrice) return nil } // SetTakeProfit Set take-profit order (implements Trader interface) +// IMPORTANT: Uses TakeProfitOrder type (type=4) with TriggerPrice, NOT regular limit order func (t *LighterTraderV2) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error { if t.txClient == nil { return fmt.Errorf("TxClient not initialized") } - logger.Infof("🎯 LIGHTER Setting take-profit: %s %s qty=%.4f, tp=%.2f", symbol, positionSide, quantity, takeProfitPrice) + logger.Infof("🎯 LIGHTER Setting take-profit: %s %s qty=%.4f, trigger=%.2f", symbol, positionSide, quantity, takeProfitPrice) - // Determine order direction (short position uses buy order, long position uses sell order) + // Determine order direction (long position uses sell order, short position uses buy order) isAsk := (positionSide == "LONG" || positionSide == "long") - // Create limit take-profit order - _, err := t.CreateOrder(symbol, isAsk, quantity, takeProfitPrice, "limit") + // Create take-profit order with TriggerPrice (type=4: TakeProfitOrder) + _, err := t.CreateStopOrder(symbol, isAsk, quantity, takeProfitPrice, "take_profit") if err != nil { return fmt.Errorf("failed to set take-profit: %w", err) } - logger.Infof("✓ LIGHTER take-profit set: %.2f", takeProfitPrice) + logger.Infof("✓ LIGHTER take-profit set: trigger=%.2f", takeProfitPrice) return nil } diff --git a/trader/lighter_trader_v2_trading.go b/trader/lighter_trader_v2_trading.go index 52c8a886..4054905d 100644 --- a/trader/lighter_trader_v2_trading.go +++ b/trader/lighter_trader_v2_trading.go @@ -23,18 +23,23 @@ func (t *LighterTraderV2) OpenLong(symbol string, quantity float64, leverage int logger.Infof("📈 LIGHTER opening long: %s, qty=%.4f, leverage=%dx", symbol, quantity, leverage) - // 1. Set leverage (if needed) + // 1. First cancel all pending orders for this symbol (clean up old stop-loss and take-profit orders) + if err := t.CancelAllOrders(symbol); err != nil { + logger.Infof("⚠️ Failed to cancel old pending orders: %v", err) + } + + // 2. Set leverage (if needed) if err := t.SetLeverage(symbol, leverage); err != nil { logger.Infof("⚠️ Failed to set leverage: %v", err) } - // 2. Get market price + // 3. Get market price marketPrice, err := t.GetMarketPrice(symbol) if err != nil { return nil, fmt.Errorf("failed to get market price: %w", err) } - // 3. Create market buy order (open long) + // 4. Create market buy order (open long) orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market") if err != nil { return nil, fmt.Errorf("failed to open long: %w", err) @@ -59,18 +64,23 @@ func (t *LighterTraderV2) OpenShort(symbol string, quantity float64, leverage in logger.Infof("📉 LIGHTER opening short: %s, qty=%.4f, leverage=%dx", symbol, quantity, leverage) - // 1. Set leverage + // 1. First cancel all pending orders for this symbol (clean up old stop-loss and take-profit orders) + if err := t.CancelAllOrders(symbol); err != nil { + logger.Infof("⚠️ Failed to cancel old pending orders: %v", err) + } + + // 2. Set leverage if err := t.SetLeverage(symbol, leverage); err != nil { logger.Infof("⚠️ Failed to set leverage: %v", err) } - // 2. Get market price + // 3. Get market price marketPrice, err := t.GetMarketPrice(symbol) if err != nil { return nil, fmt.Errorf("failed to get market price: %w", err) } - // 3. Create market sell order (open short) + // 4. Create market sell order (open short) orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market") if err != nil { return nil, fmt.Errorf("failed to open short: %w", err) @@ -563,6 +573,107 @@ func (t *LighterTraderV2) SetMarginMode(symbol string, isCrossMargin bool) error return nil } +// CreateStopOrder Create stop-loss or take-profit order with TriggerPrice +// Order types: "stop_loss" (type=2), "take_profit" (type=4) +func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity float64, triggerPrice float64, orderType string) (map[string]interface{}, error) { + if t.txClient == nil { + return nil, fmt.Errorf("TxClient not initialized") + } + + // Get market index + marketIndexU16, err := t.getMarketIndex(symbol) + if err != nil { + return nil, fmt.Errorf("failed to get market index: %w", err) + } + marketIndex := uint8(marketIndexU16) + + // Build order request + clientOrderIndex := time.Now().UnixMilli() % 281474976710655 + + // Order type: StopLossOrder=2, TakeProfitOrder=4 + var orderTypeValue uint8 = 2 // Default: StopLossOrder + if orderType == "take_profit" { + orderTypeValue = 4 // TakeProfitOrder + } + + // Convert quantity to base amount + sizeDecimals := 4 + normalizedSymbol := normalizeSymbol(symbol) + switch normalizedSymbol { + case "BTC": + sizeDecimals = 5 + case "SOL": + sizeDecimals = 3 + case "ETH": + sizeDecimals = 4 + } + baseAmount := int64(quantity * float64(pow10(sizeDecimals))) + + // TriggerPrice: price precision is 2 decimals (multiply by 100) + triggerPriceValue := uint32(triggerPrice * 1e2) + + // For stop orders, Price should be set to a reasonable execution price + // Stop-loss sell: price slightly below trigger (95% of trigger) + // Take-profit sell: price slightly below trigger (95% of trigger) + // Stop-loss buy: price slightly above trigger (105% of trigger) + // Take-profit buy: price slightly above trigger (105% of trigger) + var priceValue uint32 + if isAsk { + // Sell order - set price at 95% of trigger to ensure execution + priceValue = uint32(triggerPrice * 0.95 * 1e2) + } else { + // Buy order - set price at 105% of trigger to ensure execution + priceValue = uint32(triggerPrice * 1.05 * 1e2) + } + + // Stop orders use GoodTillTime with expiry + orderExpiry := time.Now().Add(30 * 24 * time.Hour).UnixMilli() // 30 days + + txReq := &types.CreateOrderTxReq{ + MarketIndex: marketIndex, + ClientOrderIndex: clientOrderIndex, + BaseAmount: baseAmount, + Price: priceValue, + IsAsk: boolToUint8(isAsk), + Type: orderTypeValue, + TimeInForce: 1, // GoodTillTime + ReduceOnly: 1, // Stop orders should be reduce-only + TriggerPrice: triggerPriceValue, + OrderExpiry: orderExpiry, + } + + // Sign transaction + nonce := int64(-1) + tx, err := t.txClient.GetCreateOrderTransaction(txReq, &types.TransactOpts{ + Nonce: &nonce, + }) + if err != nil { + return nil, fmt.Errorf("failed to sign stop order: %w", err) + } + + // Get tx_info + txInfo, err := tx.GetTxInfo() + if err != nil { + return nil, fmt.Errorf("failed to get tx info: %w", err) + } + + logger.Infof("DEBUG stop order - type: %d, trigger: %.2f, price: %.2f, isAsk: %v", orderTypeValue, triggerPrice, float64(priceValue)/100, isAsk) + + // Submit order + orderResp, err := t.submitOrder(int(tx.GetTxType()), txInfo) + if err != nil { + return nil, fmt.Errorf("failed to submit stop order: %w", err) + } + + side := "buy" + if isAsk { + side = "sell" + } + logger.Infof("✓ LIGHTER %s order created: %s %s qty=%.4f trigger=%.2f", orderType, symbol, side, quantity, triggerPrice) + + return orderResp, nil +} + // boolToUint8 Convert boolean to uint8 func boolToUint8(b bool) uint8 { if b { diff --git a/web/src/components/DecisionCard.tsx b/web/src/components/DecisionCard.tsx index 9b4b74cf..a6007b36 100644 --- a/web/src/components/DecisionCard.tsx +++ b/web/src/components/DecisionCard.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { DecisionRecord } from '../types' +import type { DecisionRecord, DecisionAction } from '../types' import { t, type Language } from '../i18n/translations' interface DecisionCardProps { @@ -7,154 +7,339 @@ interface DecisionCardProps { language: Language } +// Action type configuration +const ACTION_CONFIG: Record = { + open_long: { color: '#0ECB81', bg: 'rgba(14, 203, 129, 0.15)', icon: '📈', label: 'LONG' }, + open_short: { color: '#F6465D', bg: 'rgba(246, 70, 93, 0.15)', icon: '📉', label: 'SHORT' }, + close_long: { color: '#F0B90B', bg: 'rgba(240, 185, 11, 0.15)', icon: '💰', label: 'CLOSE' }, + close_short: { color: '#F0B90B', bg: 'rgba(240, 185, 11, 0.15)', icon: '💰', label: 'CLOSE' }, + hold: { color: '#848E9C', bg: 'rgba(132, 142, 156, 0.15)', icon: '⏸️', label: 'HOLD' }, + wait: { color: '#848E9C', bg: 'rgba(132, 142, 156, 0.15)', icon: '⏳', label: 'WAIT' }, +} + +// Format price with proper decimals +function formatPrice(price: number | undefined): string { + if (!price || price === 0) return '-' + if (price >= 1000) return price.toFixed(2) + if (price >= 1) return price.toFixed(4) + return price.toFixed(6) +} + +// Calculate percentage change +function calcPctChange(entry: number | undefined, target: number | undefined, isLong: boolean): string { + if (!entry || !target || entry === 0) return '-' + const pct = ((target - entry) / entry) * 100 + const adjustedPct = isLong ? pct : -pct + return `${adjustedPct >= 0 ? '+' : ''}${adjustedPct.toFixed(2)}%` +} + +// Get confidence color +function getConfidenceColor(confidence: number | undefined): string { + if (!confidence) return '#848E9C' + if (confidence >= 80) return '#0ECB81' + if (confidence >= 60) return '#F0B90B' + return '#F6465D' +} + +// Single Action Card Component +function ActionCard({ action, language }: { action: DecisionAction; language: Language }) { + const config = ACTION_CONFIG[action.action] || ACTION_CONFIG.wait + const isLong = action.action.includes('long') + const isOpen = action.action.includes('open') + + return ( +
+ {/* Header Row */} +
+
+ {config.icon} + + {action.symbol.replace('USDT', '')} + + + {config.label} + +
+ + {/* Status Badge */} +
+ {action.confidence !== undefined && action.confidence > 0 && ( +
+ {action.confidence.toFixed(0)}% +
+ )} +
+
+
+ + {/* Trading Details Grid */} + {isOpen && ( +
+ {/* Entry Price */} +
+
+ {t('entryPrice', language)} +
+
+ {formatPrice(action.price)} +
+
+ + {/* Stop Loss */} +
+
+ {t('stopLoss', language)} +
+
+ {formatPrice(action.stop_loss)} +
+ {action.stop_loss && action.price && ( +
+ {calcPctChange(action.price, action.stop_loss, isLong)} +
+ )} +
+ + {/* Take Profit */} +
+
+ {t('takeProfit', language)} +
+
+ {formatPrice(action.take_profit)} +
+ {action.take_profit && action.price && ( +
+ {calcPctChange(action.price, action.take_profit, isLong)} +
+ )} +
+ + {/* Leverage */} +
+
+ {t('leverage', language)} +
+
+ {action.leverage}x +
+
+
+ )} + + {/* Risk/Reward Ratio for open positions */} + {isOpen && action.stop_loss && action.take_profit && action.price && ( +
+ {t('riskReward', language)} +
+ {(() => { + const slDist = Math.abs(action.price - action.stop_loss) + const tpDist = Math.abs(action.take_profit - action.price) + const ratio = slDist > 0 ? (tpDist / slDist) : 0 + const ratioColor = ratio >= 3 ? '#0ECB81' : ratio >= 2 ? '#F0B90B' : '#F6465D' + return ( + <> +
+ 1 + : + {ratio.toFixed(1)} +
+
+
+
+ + ) + })()} +
+
+ )} + + {/* Reasoning */} + {action.reasoning && ( +
+
+ 💡 {action.reasoning} +
+
+ )} + + {/* Error Message */} + {action.error && ( +
+ ❌ {action.error} +
+ )} +
+ ) +} + export function DecisionCard({ decision, language }: DecisionCardProps) { const [showInputPrompt, setShowInputPrompt] = useState(false) const [showCoT, setShowCoT] = useState(false) return (
-
-
-
- {t('cycle', language)} #{decision.cycle_number} + {/* Header */} +
+
+
+ 🤖
-
- {new Date(decision.timestamp).toLocaleString()} +
+
+ {t('cycle', language)} #{decision.cycle_number} +
+
+ {new Date(decision.timestamp).toLocaleString()} +
{t(decision.success ? 'success' : 'failed', language)}
- {decision.input_prompt && ( -
- - {showInputPrompt && ( -
- {decision.input_prompt} -
- )} -
- )} - - {decision.cot_trace && ( -
- - {showCoT && ( -
- {decision.cot_trace} -
- )} -
- )} - + {/* Decision Actions - Beautiful Grid */} {decision.decisions && decision.decisions.length > 0 && ( -
+
{decision.decisions.map((action, index) => ( -
- - {action.symbol} - - - {action.action} - - {action.reasoning && ( - - {action.reasoning} - - )} -
+ ))}
)} + {/* Collapsible Sections */} +
+ {/* Input Prompt */} + {decision.input_prompt && ( +
+ + {showInputPrompt && ( +
+ {decision.input_prompt} +
+ )} +
+ )} + + {/* AI Thinking */} + {decision.cot_trace && ( +
+ + {showCoT && ( +
+ {decision.cot_trace} +
+ )} +
+ )} +
+ + {/* Execution Log */} {decision.execution_log && decision.execution_log.length > 0 && (
{decision.execution_log.map((log, index) => ( @@ -165,9 +350,10 @@ export function DecisionCard({ decision, language }: DecisionCardProps) {
)} + {/* Error Message */} {decision.error_message && (