2026-05-07 09:56:21 +08:00
|
|
|
|
package logic
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"log"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
|
|
|
|
|
|
"git.apinb.com/quant/coin/internal/models"
|
|
|
|
|
|
"github.com/adshao/go-binance/v2"
|
|
|
|
|
|
"github.com/shopspring/decimal"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// trySpotRallySell 跟踪止盈:浮盈≥profitArmPct 后记录阶段最高价,不在该涨幅直接平仓;
|
|
|
|
|
|
// 仅当现价从本轮高点回撤≥trailPullbackPct 时市价卖出全部可用基础资产。
|
|
|
|
|
|
func trySpotRallySell(ctx context.Context, client *binance.Client, w spotSymbol, price float64, st *models.SpotPosition, free float64) error {
|
|
|
|
|
|
cost := st.AvgCostUSDT
|
|
|
|
|
|
if cost <= 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
armLine := cost * (1 + profitArmPct)
|
|
|
|
|
|
|
|
|
|
|
|
if !st.TrailArmed {
|
|
|
|
|
|
if price >= armLine {
|
|
|
|
|
|
st.TrailArmed = true
|
|
|
|
|
|
st.TrailPeakUSDT = price
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if price > st.TrailPeakUSDT {
|
|
|
|
|
|
st.TrailPeakUSDT = price
|
|
|
|
|
|
}
|
|
|
|
|
|
sellLine := st.TrailPeakUSDT * (1 - trailPullbackPct)
|
|
|
|
|
|
if price >= sellLine {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
qtyStr, ok, err := formatQtyToLotStep(free, spotLotStep(w.Symbol))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-05-08 23:37:34 +08:00
|
|
|
|
if !ok {
|
|
|
|
|
|
log.Printf("logic: %s 跟踪止盈回撤触发但可卖数量按 LOT 步长取整为 0(free=%.12f),解除跟踪;dust 请手工或下轮余额变化后再管", w.Symbol, free)
|
|
|
|
|
|
resetSpotTrail(st)
|
|
|
|
|
|
markSpotPortfolioDirty()
|
2026-05-07 09:56:21 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-05-08 23:37:34 +08:00
|
|
|
|
nominal := parseFloat(qtyStr) * price
|
|
|
|
|
|
if nominal < minSellNotional {
|
|
|
|
|
|
log.Printf("logic: %s 跟踪止盈:卖出名义约 %.4f USDT 低于参考门槛 %.2f,仍尝试市价可卖尽卖", w.Symbol, nominal, minSellNotional)
|
|
|
|
|
|
}
|
2026-05-07 09:56:21 +08:00
|
|
|
|
order, err := client.NewCreateOrderService().
|
|
|
|
|
|
Symbol(w.Symbol).
|
|
|
|
|
|
Side(binance.SideTypeSell).
|
|
|
|
|
|
Type(binance.OrderTypeMarket).
|
|
|
|
|
|
Quantity(qtyStr).
|
|
|
|
|
|
NewOrderRespType(binance.NewOrderRespTypeFULL).
|
|
|
|
|
|
Do(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
sold, _ := strconv.ParseFloat(order.ExecutedQuantity, 64)
|
|
|
|
|
|
st.Quantity = free - sold
|
|
|
|
|
|
if st.Quantity < 0 {
|
|
|
|
|
|
st.Quantity = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
peak := st.TrailPeakUSDT
|
|
|
|
|
|
resetSpotTrail(st)
|
2026-05-08 23:37:34 +08:00
|
|
|
|
markSpotPortfolioDirty()
|
2026-05-07 09:56:21 +08:00
|
|
|
|
log.Printf("logic: %s 从跟踪高点 %.6f 回撤≥%.1f%% 全平, 卖出数量 %s", w.Symbol, peak, trailPullbackPct*100, qtyStr)
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// spotLotStep 返回交易对 LOT_SIZE 步长;未加载 exchangeInfo 时用保守默认。
|
|
|
|
|
|
func spotLotStep(symbol string) string {
|
|
|
|
|
|
if s := stepSizes[symbol]; s != "" {
|
|
|
|
|
|
return s
|
|
|
|
|
|
}
|
|
|
|
|
|
return "0.00001"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// formatQtyToLotStep 将数量按 stepSize 向下取整,满足 Binance LOT_SIZE(买卖共用);过小则返回 ok=false。
|
|
|
|
|
|
func formatQtyToLotStep(qty float64, stepSize string) (string, bool, error) {
|
|
|
|
|
|
if qty <= 0 {
|
|
|
|
|
|
return "", false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
step, err := decimal.NewFromString(stepSize)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", false, err
|
|
|
|
|
|
}
|
|
|
|
|
|
q := decimal.NewFromFloat(qty)
|
|
|
|
|
|
n := q.Div(step).Floor()
|
|
|
|
|
|
out := n.Mul(step)
|
|
|
|
|
|
if out.LessThanOrEqual(decimal.Zero) {
|
|
|
|
|
|
return "", false, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return out.String(), true, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func parseFloat(s string) float64 {
|
|
|
|
|
|
v, _ := strconv.ParseFloat(s, 64)
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|