Files
coin/internal/logic/spot_binance.go
2026-05-07 09:56:21 +08:00

254 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package logic 提供 Binance 现货策略;本组文件为定时轮询(非 WebSocket的建仓、超跌加仓与跟踪止盈全平。
package logic
import (
"context"
"log"
"strconv"
"strings"
"sync"
"time"
"git.apinb.com/quant/coin/internal/config"
"git.apinb.com/quant/coin/internal/models"
"github.com/adshao/go-binance/v2"
)
// 以下为可调参数,改数值即可调整策略行为(改后重新编译运行)。
const (
// pollInterval 两次完整轮询之间的间隔(拉余额、价格、判单、写库)
pollInterval = 60 * time.Second
// maxDipAdds 相对当前均价最多加仓次数(不含首仓)
maxDipAdds = 4
// profitArmPct 浮盈达到该比例后进入「跟踪高点」:继续上涨不平仓,仅从高点回撤 trailPullbackPct 时全平
profitArmPct = 0.05
// trailPullbackPct 从跟踪期内最高价回撤本比例则触发市价全仓卖出
trailPullbackPct = 0.005
// minHoldUSDT 持仓市值低于此值USDT视为「无仓」会按配置 OrderQty 市价买基础币建首笔
minHoldUSDT = 10.0
// minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional
minSellNotional = 10.0
// dipRecoverPct 超跌加仓后,现价需高于「成本×(1+本值)」才解除加仓锁,避免同一低位反复买
dipRecoverPct = 0.03
// buyReboundPct 开仓/加仓前须相对阶段低点反弹超过本比例才市价买入(持续创新低则不买)
buyReboundPct = 0.0039
)
// spotSymbol 单标的运行时视图:基础资产、交易对、每笔买入的基础币数量(来自配置 SpotWatchList
type spotSymbol struct {
Base string
Symbol string // 如 BTCUSDT
OrderQty float64 // 开仓与加仓共用(枚)
}
// spotWatchesFromConfig 从 Spec.SpotWatchList 解析有效项Symbol 空或 OrderQty≤0 的条目会被跳过。
func spotWatchesFromConfig() []spotSymbol {
items := config.Spec.SpotWatchList
out := make([]spotSymbol, 0, len(items))
for _, it := range items {
sym := strings.TrimSpace(it.Symbol)
if sym == "" || it.OrderQty <= 0 {
continue
}
us := strings.ToUpper(sym)
out = append(out, spotSymbol{
Base: spotBaseFromSymbol(us),
Symbol: us,
OrderQty: it.OrderQty,
})
}
return out
}
// spotBaseFromSymbol 由 USDT 现货交易对推导基础资产名(如 BTCUSDT → BTC非 *USDT 后缀则整体大写作为键。
func spotBaseFromSymbol(symbolUpper string) string {
if strings.HasSuffix(symbolUpper, "USDT") && len(symbolUpper) > 4 {
return strings.TrimSuffix(symbolUpper, "USDT")
}
return symbolUpper
}
// dipAddDrawdowns[k] 为已完成 k 次加仓后,下一次加仓须达到的相对当前加权均价的跌幅(第 1 次 5%、第 2 次 15%…)。
var dipAddDrawdowns = [...]float64{0.05, 0.15, 0.30, 0.50}
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
}
if len(spotWatchesFromConfig()) == 0 {
log.Printf("logic: SpotWatchList 未配置或无效,跳过现货策略")
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
}
}
// spotTick 单次轮询:拉账户 + 批量现价 → 对每个配置标的执行 processOne → 持久化到库。
func spotTick(ctx context.Context, client *binance.Client) error {
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
watch := spotWatchesFromConfig()
if len(watch) == 0 {
return nil
}
acct, err := client.NewGetAccountService().Do(ctx)
if err != nil {
return err
}
symbols := make([]string, len(watch))
for i, w := range watch {
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 watch {
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
if positionUSDT >= minHoldUSDT {
st.OpenReboundLow = 0
}
if positionUSDT < minHoldUSDT {
st.DipAddsDone = 0
st.DipLegLocked = false
st.RallyLegLocked = false
resetSpotTrail(st)
if !spotReboundReady(&st.OpenReboundLow, price) {
return nil
}
err = trySpotInitialEntry(ctx, client, balances, w, st, price)
if err == nil {
st.OpenReboundLow = 0
}
return err
}
if st.AvgCostUSDT <= 0 {
st.AvgCostUSDT = price
}
st.Quantity = free
if err := trySpotDipAdd(ctx, client, balances, w, price, st); err != nil {
return err
}
if st.DipLegLocked && price >= st.AvgCostUSDT*(1+dipRecoverPct) {
st.DipLegLocked = false
}
if err := trySpotRallySell(ctx, client, w, price, st, free); err != nil {
return err
}
return nil
}
// resetSpotTrail 清除跟踪止盈状态(空仓、加仓后成本变化时调用)。
func resetSpotTrail(st *models.SpotPosition) {
st.TrailArmed = false
st.TrailPeakUSDT = 0
}
// spotReboundReady 用 *latch 跟踪阶段低价:创新低则只下移 latch 不通过;现价相对 latch 反弹≥buyReboundPct 时返回 true。
// *latch≤0 时锚定 latch=price 并返回 false首根 K 不直接买)。
func spotReboundReady(latch *float64, price float64) bool {
if *latch <= 0 {
*latch = price
return false
}
if price < *latch {
*latch = price
return false
}
return price >= *latch*(1+buyReboundPct)
}