// Package logic 提供 Binance 现货策略;本文件为定时轮询(非 WebSocket)的建仓、超跌加仓与冲高减仓。 package logic import ( "context" "errors" "log" "strconv" "sync" "time" "git.apinb.com/quant/coin/internal/config" "git.apinb.com/quant/coin/internal/impl" "git.apinb.com/quant/coin/internal/models" "github.com/adshao/go-binance/v2" "github.com/shopspring/decimal" ) // 以下为可调参数,改数值即可调整策略行为(改后重新编译运行)。 const ( // pollInterval 两次完整轮询之间的间隔(拉余额、价格、判单、写库) pollInterval = 60 * time.Second // buyQuoteUSDT 每次市价买入使用的报价资产数量(USDT) buyQuoteUSDT = 100.0 // dipPct 相对成本价的下跌比例,跌破则触发加仓(若本波未锁) dipPct = 0.10 // profitPct 相对成本价的上涨比例,涨破则触发减仓(若本波未锁) profitPct = 0.10 // sellFraction 上涨触发时,卖出当前可用基础资产的比例 sellFraction = 0.20 // minHoldUSDT 持仓市值低于此值(USDT)视为「无仓」,会先建首笔 100U minHoldUSDT = 10.0 // minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional) minSellNotional = 10.0 // dipRecoverPct 超跌加仓后,现价需高于「成本×(1+本值)」才解除加仓锁,避免同一低位反复买 dipRecoverPct = 0.03 // rallyResetHighPct 冲高减仓后,现价需低于「成本×(1+本值)」才解除减仓锁,避免同一高位反复卖 rallyResetHighPct = 0.06 ) // spotSymbol 把「账户里的资产名」和「交易对」绑在一起,避免硬编码散落各处。 type spotSymbol struct { Base string // 如 BTC Symbol string // 如 BTCUSDT } // watchList 策略监控的标的列表;增删币种时改此处即可。 var watchList = []spotSymbol{ {"BTC", "BTCUSDT"}, {"ETH", "ETHUSDT"}, {"SOL", "SOLUSDT"}, {"XRP", "XRPUSDT"}, } var ( portfolioMu sync.Mutex // 保护 portfolio 与数据库写入,避免并发轮询(若以后拆协程) portfolio = models.NewSpotPortfolioSnapshot() // stepSizes 各交易对 LOT_SIZE 的 stepSize,用于卖出数量按交易所步长向下取整 stepSizes = map[string]string{} ) // runBinanceSpotStrategy 入口:校验配置 → 连通性 → 死循环定时执行 spotTick。 // 若 Key/Secret 为空或 API 失败会直接 return,不再占用协程(由 Boot 调用方决定是否在 goroutine 里跑)。 func runBinanceSpotStrategy() { ctx := context.Background() key := config.Spec.BinanceApiKey secret := config.Spec.BinanceApiSecret if key == "" || secret == "" { log.Printf("logic: 未配置 BinanceApiKey 或 BinanceApiSecret,跳过现货策略") return } client := binance.NewClient(key, secret) if err := client.NewPingService().Do(ctx); err != nil { log.Printf("logic: Binance Ping 失败: %v", err) return } acct, err := client.NewGetAccountService().Do(ctx) if err != nil { log.Printf("logic: Binance 账户校验失败: %v", err) return } if !acct.CanTrade { log.Printf("logic: Binance 账户未开启现货交易权限") return } log.Printf("logic: Binance 已连接,CanTrade=%v", acct.CanTrade) if err := loadPortfolio(); err != nil { log.Printf("logic: 从数据库加载现货持仓失败(将使用空档): %v", err) } if err := refreshStepSizes(ctx, client); err != nil { log.Printf("logic: 加载交易对精度失败: %v", err) } ticker := time.NewTicker(pollInterval) defer ticker.Stop() for { if err := spotTick(ctx, client); err != nil { log.Printf("logic: 现货策略轮询错误: %v", err) } <-ticker.C } } // refreshStepSizes 从 exchangeInfo 拉取各 symbol 的 LOT_SIZE.stepSize,供卖出数量格式化。 func refreshStepSizes(ctx context.Context, client *binance.Client) error { syms := make([]string, 0, len(watchList)) for _, w := range watchList { syms = append(syms, w.Symbol) } info, err := client.NewExchangeInfoService().Symbols(syms...).Do(ctx) if err != nil { return err } for _, s := range info.Symbols { lot := s.LotSizeFilter() if lot == nil || lot.StepSize == "" { continue } stepSizes[s.Symbol] = lot.StepSize } return nil } // loadPortfolio 从 GORM 读入全表 spot_positions,填充内存 map(键为 BaseAsset)。 func loadPortfolio() error { portfolioMu.Lock() defer portfolioMu.Unlock() if impl.DBService == nil { portfolio = models.NewSpotPortfolioSnapshot() return nil } var rows []models.SpotPosition if err := impl.DBService.Find(&rows).Error; err != nil { return err } portfolio = models.NewSpotPortfolioSnapshot() for i := range rows { p := new(models.SpotPosition) *p = rows[i] portfolio.Positions[p.BaseAsset] = p } return nil } // savePortfolioLocked 将内存中各 SpotPosition 以 Save 写回数据库(有主键则更新,无则插入)。 // 调用方必须已持有 portfolioMu。 func savePortfolioLocked() error { if impl.DBService == nil { return nil } for _, st := range portfolio.Positions { if st == nil { continue } if err := impl.DBService.Save(st).Error; err != nil { return err } } return nil } // balanceFree 从账户余额列表里解析某资产的可用数量(Free 字段为字符串)。 func balanceFree(balances []binance.Balance, asset string) (float64, error) { for _, b := range balances { if b.Asset == asset { return strconv.ParseFloat(b.Free, 64) } } return 0, nil } // spotTick 单次轮询:拉账户 + 批量现价 → 对每个 watchList 标的执行 processOne → 持久化到库。 func spotTick(ctx context.Context, client *binance.Client) error { acct, err := client.NewGetAccountService().Do(ctx) if err != nil { return err } symbols := make([]string, len(watchList)) for i, w := range watchList { symbols[i] = w.Symbol } prices, err := client.NewListPricesService().Symbols(symbols).Do(ctx) if err != nil { return err } priceBySymbol := make(map[string]float64, len(prices)) for _, p := range prices { v, err := strconv.ParseFloat(p.Price, 64) if err != nil { continue } priceBySymbol[p.Symbol] = v } portfolioMu.Lock() defer portfolioMu.Unlock() for _, w := range watchList { px, ok := priceBySymbol[w.Symbol] if !ok { continue } if err := processOne(ctx, client, acct.Balances, w, px); err != nil { log.Printf("logic: %s 处理失败: %v", w.Symbol, err) } } return savePortfolioLocked() } // getOrCreateState 按基础资产取状态;不存在则新建并挂到 portfolio 上(首轮 Save 时插入表)。 func getOrCreateState(base, symbol string) *models.SpotPosition { st, ok := portfolio.Positions[base] if !ok { st = &models.SpotPosition{ BaseAsset: base, Symbol: symbol, } portfolio.Positions[base] = st } return st } // processOne 单标的一轮决策:无仓建仓 → 有仓则判超跌买 / 冲高卖,并维护锁与成本。 func processOne(ctx context.Context, client *binance.Client, balances []binance.Balance, w spotSymbol, price float64) error { free, err := balanceFree(balances, w.Base) if err != nil { return err } st := getOrCreateState(w.Base, w.Symbol) positionUSDT := free * price // ---------- 分支一:视为空仓,首笔市价买 100 USDT ---------- if positionUSDT < minHoldUSDT { st.DipLegLocked = false st.RallyLegLocked = false usdtFree, err := balanceFree(balances, "USDT") if err != nil { return err } if usdtFree < buyQuoteUSDT { return errors.New("USDT 余额不足,无法建仓 100U") } order, err := client.NewCreateOrderService(). Symbol(w.Symbol). Side(binance.SideTypeBuy). Type(binance.OrderTypeMarket). QuoteOrderQty(strconv.FormatFloat(buyQuoteUSDT, 'f', 2, 64)). NewOrderRespType(binance.NewOrderRespTypeFULL). Do(ctx) if err != nil { return err } applyBuyFill(st, order) log.Printf("logic: %s 初始建仓约 %.2f USDT, 成交均价约 %.4f", w.Symbol, buyQuoteUSDT, st.AvgCostUSDT) return nil } // 有仓但本地从未写过成本(例如手工转入),用当前价初始化成本,便于后续比例判断 if st.AvgCostUSDT <= 0 { st.AvgCostUSDT = price } st.Quantity = free // ---------- 分支二:相对成本下跌 dipPct,且本波未加仓过 → 再买 100 USDT ---------- dipLine := st.AvgCostUSDT * (1 - dipPct) if price <= dipLine && !st.DipLegLocked { usdtFree, err := balanceFree(balances, "USDT") if err != nil { return err } if usdtFree < buyQuoteUSDT { return errors.New("USDT 余额不足,无法加仓 100U") } order, err := client.NewCreateOrderService(). Symbol(w.Symbol). Side(binance.SideTypeBuy). Type(binance.OrderTypeMarket). QuoteOrderQty(strconv.FormatFloat(buyQuoteUSDT, 'f', 2, 64)). NewOrderRespType(binance.NewOrderRespTypeFULL). Do(ctx) if err != nil { return err } applyBuyFill(st, order) st.DipLegLocked = true log.Printf("logic: %s 下跌 %.0f%% 加仓 %.0f USDT, 新成本约 %.4f", w.Symbol, dipPct*100, buyQuoteUSDT, st.AvgCostUSDT) } // 价格明显站回成本上方后,允许下一次「超跌加仓」 if st.DipLegLocked && price >= st.AvgCostUSDT*(1+dipRecoverPct) { st.DipLegLocked = false } // ---------- 分支三:相对成本上涨 profitPct,且本波未减仓过 → 卖出 free 的 sellFraction ---------- profitLine := st.AvgCostUSDT * (1 + profitPct) if price >= profitLine && !st.RallyLegLocked { step := stepSizes[w.Symbol] if step == "" { step = "0.00001" } qtyStr, ok, err := formatQtyForSell(free*sellFraction, step) if err != nil { return err } if !ok || parseFloat(qtyStr)*price < minSellNotional { return nil } 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 } st.RallyLegLocked = true log.Printf("logic: %s 上涨 %.0f%% 减持 %.0f%%, 卖出数量 %s", w.Symbol, profitPct*100, sellFraction*100, qtyStr) } // 从高位回落靠近成本后,允许下一次「冲高减仓」 if st.RallyLegLocked && price <= st.AvgCostUSDT*(1+rallyResetHighPct) { st.RallyLegLocked = false } return nil } // applyBuyFill 根据市价买单成交回报更新加权成本与数量(首笔建仓与加仓共用)。 func applyBuyFill(st *models.SpotPosition, order *binance.CreateOrderResponse) { execQty, _ := strconv.ParseFloat(order.ExecutedQuantity, 64) quote, _ := strconv.ParseFloat(order.CummulativeQuoteQuantity, 64) if execQty <= 0 || quote <= 0 { return } oldQty := st.Quantity oldCost := st.AvgCostUSDT newQty := oldQty + execQty if newQty <= 0 { return } if oldQty <= 0 || oldCost <= 0 { st.AvgCostUSDT = quote / execQty } else { st.AvgCostUSDT = (oldQty*oldCost + quote) / newQty } st.Quantity = newQty } // formatQtyForSell 将「计划卖出数量」按 stepSize 向下取整,满足 Binance LOT_SIZE;过小则返回 ok=false。 func formatQtyForSell(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 }