2026-05-07 09:56:21 +08:00
|
|
|
|
// Package logic 提供 Binance 现货策略;本组文件为定时轮询(非 WebSocket)的建仓、超跌加仓与跟踪止盈全平。
|
2026-05-07 00:47:09 +08:00
|
|
|
|
package logic
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"log"
|
|
|
|
|
|
"strconv"
|
2026-05-07 09:56:21 +08:00
|
|
|
|
"strings"
|
2026-05-07 00:47:09 +08:00
|
|
|
|
"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
|
|
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
// maxDipAdds 相对当前均价最多加仓次数(不含首仓)
|
|
|
|
|
|
maxDipAdds = 4
|
|
|
|
|
|
// profitArmPct 浮盈达到该比例后进入「跟踪高点」:继续上涨不平仓,仅从高点回撤 trailPullbackPct 时全平
|
|
|
|
|
|
profitArmPct = 0.05
|
|
|
|
|
|
// trailPullbackPct 从跟踪期内最高价回撤本比例则触发市价全仓卖出
|
|
|
|
|
|
trailPullbackPct = 0.005
|
2026-05-07 00:47:09 +08:00
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
// minHoldUSDT 持仓市值低于此值(USDT)视为「无仓」,会按配置 OrderQty 市价买基础币建首笔
|
2026-05-07 00:47:09 +08:00
|
|
|
|
minHoldUSDT = 10.0
|
|
|
|
|
|
// minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional)
|
|
|
|
|
|
minSellNotional = 10.0
|
|
|
|
|
|
|
|
|
|
|
|
// dipRecoverPct 超跌加仓后,现价需高于「成本×(1+本值)」才解除加仓锁,避免同一低位反复买
|
|
|
|
|
|
dipRecoverPct = 0.03
|
2026-05-07 09:56:21 +08:00
|
|
|
|
// buyReboundPct 开仓/加仓前须相对阶段低点反弹超过本比例才市价买入(持续创新低则不买)
|
|
|
|
|
|
buyReboundPct = 0.0039
|
2026-05-07 00:47:09 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
// spotSymbol 单标的运行时视图:基础资产、交易对、每笔买入的基础币数量(来自配置 SpotWatchList)。
|
2026-05-07 00:47:09 +08:00
|
|
|
|
type spotSymbol struct {
|
2026-05-07 09:56:21 +08:00
|
|
|
|
Base string
|
|
|
|
|
|
Symbol string // 如 BTCUSDT
|
|
|
|
|
|
OrderQty float64 // 开仓与加仓共用(枚)
|
2026-05-07 00:47:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
// 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
|
2026-05-07 00:47:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
// dipAddDrawdowns[k] 为已完成 k 次加仓后,下一次加仓须达到的相对当前加权均价的跌幅(第 1 次 5%、第 2 次 15%…)。
|
|
|
|
|
|
var dipAddDrawdowns = [...]float64{0.05, 0.15, 0.30, 0.50}
|
|
|
|
|
|
|
2026-05-07 00:47:09 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-05-07 09:56:21 +08:00
|
|
|
|
if len(spotWatchesFromConfig()) == 0 {
|
|
|
|
|
|
log.Printf("logic: SpotWatchList 未配置或无效,跳过现货策略")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-07 00:47:09 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
// 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 {
|
2026-05-07 00:47:09 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
acct, err := client.NewGetAccountService().Do(ctx)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
2026-05-07 09:56:21 +08:00
|
|
|
|
symbols := make([]string, len(watch))
|
|
|
|
|
|
for i, w := range watch {
|
2026-05-07 00:47:09 +08:00
|
|
|
|
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()
|
|
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
for _, w := range watch {
|
2026-05-07 00:47:09 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
if positionUSDT >= minHoldUSDT {
|
|
|
|
|
|
st.OpenReboundLow = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 00:47:09 +08:00
|
|
|
|
if positionUSDT < minHoldUSDT {
|
2026-05-07 09:56:21 +08:00
|
|
|
|
st.DipAddsDone = 0
|
2026-05-07 00:47:09 +08:00
|
|
|
|
st.DipLegLocked = false
|
|
|
|
|
|
st.RallyLegLocked = false
|
2026-05-07 09:56:21 +08:00
|
|
|
|
resetSpotTrail(st)
|
|
|
|
|
|
if !spotReboundReady(&st.OpenReboundLow, price) {
|
|
|
|
|
|
return nil
|
2026-05-07 00:47:09 +08:00
|
|
|
|
}
|
2026-05-07 09:56:21 +08:00
|
|
|
|
err = trySpotInitialEntry(ctx, client, balances, w, st, price)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
st.OpenReboundLow = 0
|
2026-05-07 00:47:09 +08:00
|
|
|
|
}
|
2026-05-07 09:56:21 +08:00
|
|
|
|
return err
|
2026-05-07 00:47:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if st.AvgCostUSDT <= 0 {
|
|
|
|
|
|
st.AvgCostUSDT = price
|
|
|
|
|
|
}
|
|
|
|
|
|
st.Quantity = free
|
|
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
if err := trySpotDipAdd(ctx, client, balances, w, price, st); err != nil {
|
|
|
|
|
|
return err
|
2026-05-07 00:47:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
if st.DipLegLocked && price >= st.AvgCostUSDT*(1+dipRecoverPct) {
|
|
|
|
|
|
st.DipLegLocked = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
if err := trySpotRallySell(ctx, client, w, price, st, free); err != nil {
|
|
|
|
|
|
return err
|
2026-05-07 00:47:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
// resetSpotTrail 清除跟踪止盈状态(空仓、加仓后成本变化时调用)。
|
|
|
|
|
|
func resetSpotTrail(st *models.SpotPosition) {
|
|
|
|
|
|
st.TrailArmed = false
|
|
|
|
|
|
st.TrailPeakUSDT = 0
|
2026-05-07 00:47:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 09:56:21 +08:00
|
|
|
|
// 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
|
2026-05-07 00:47:09 +08:00
|
|
|
|
}
|
2026-05-07 09:56:21 +08:00
|
|
|
|
if price < *latch {
|
|
|
|
|
|
*latch = price
|
|
|
|
|
|
return false
|
2026-05-07 00:47:09 +08:00
|
|
|
|
}
|
2026-05-07 09:56:21 +08:00
|
|
|
|
return price >= *latch*(1+buyReboundPct)
|
2026-05-07 00:47:09 +08:00
|
|
|
|
}
|