Files
coin/internal/logic/spot_binance.go
2026-05-10 23:23:38 +08:00

256 lines
8.4 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"
"fmt"
"log"
"strconv"
"strings"
"time"
"git.apinb.com/quant/coin/internal/config"
"git.apinb.com/quant/coin/internal/models"
"github.com/adshao/go-binance/v2"
)
// 以下为可调参数,改数值即可调整策略行为(改后重新编译运行)。
const (
// pollInterval 两次完整轮询之间的间隔(拉余额、价格、判单、写库)
pollInterval = 10 * time.Second
// maxDipAdds 相对当前均价最多加仓次数(不含首仓)
maxDipAdds = 4
// profitArmPct 浮盈达到该比例后进入「跟踪高点」:继续上涨不平仓,仅从高点回撤 trailPullbackPct 时全平
profitArmPct = 0.05
// trailPullbackPct 从跟踪期内最高价回撤本比例则触发市价全仓卖出
trailPullbackPct = 0.005
// minHoldUSDT 持仓市值默认「无仓」判断的全局名义下限USDT单笔 OrderQtyUsdt 接近该值时会用 spotHoldingThresholdUSDT 放宽,避免 LOT 取整后略低于本值被误判空仓。
minHoldUSDT = 10.0
// minOrderQtyUsdt SpotWatchList 单笔开仓/加仓配置的 USDT 名义下限(与常见 minNotional 对齐)。
minOrderQtyUsdt = 10.0
// minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional
minSellNotional = 10.0
// dipRecoverPct 超跌加仓后,现价需高于「成本×(1+本值)」才解除加仓锁,避免同一低位反复买
dipRecoverPct = 0.03
// buyReboundPct 开仓/加仓前须相对阶段低点反弹超过本比例才市价买入(持续创新低则不买)
buyReboundPct = 0.0039
// spotImmediateInitialOpen 为 true 时,仅当策略侧从未有过持仓(成本与数量均为 0时跳过「先锚定再等反弹」
// 启动后首轮即可市价开首仓;曾经建仓后又全平的标的仍须等反弹后再进,避免刚卖立刻买回。
spotImmediateInitialOpen = true
)
// spotSymbol 单标的运行时视图:基础资产、交易对、每笔买入的 USDT 名义(来自配置 SpotWatchList
type spotSymbol struct {
Base string
Symbol string // 如 BTCUSDT
OrderQtyUsdt float64 // 开仓与加仓共用USDT 名义)
}
// spotWatchesFromConfig 从 Spec.SpotWatchList 解析有效项Symbol 空或 OrderQtyUsdt < minOrderQtyUsdt 的条目会被跳过。
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.OrderQtyUsdt < minOrderQtyUsdt {
continue
}
us := strings.ToUpper(sym)
out = append(out, spotSymbol{
Base: spotBaseFromSymbol(us),
Symbol: us,
OrderQtyUsdt: it.OrderQtyUsdt,
})
}
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 (
// stepSizes 各交易对 LOT_SIZE 的 stepSize用于卖出数量按交易所步长向下取整
stepSizes = map[string]string{}
// spotTickDirty 本轮是否发生过市价成交(首仓/加仓/全平)并改动了需持久化的字段;无成交则不写库。
// 无成交时的 trail/反弹锚点等仅驻内存,进程异常退出可能丢失该段进度(下轮仍可从账户与价格继续推演)。
spotTickDirty bool
)
func markSpotPortfolioDirty() {
spotTickDirty = true
}
// spotTick 单次轮询:拉账户 + 批量现价 → 对每个配置标的执行 processOne → 持久化到库。
func spotTick(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
watch := spotWatchesFromConfig()
if len(watch) == 0 {
return nil
}
acct, err := BinanceClient.NewGetAccountService().Do(ctx)
if err != nil {
return err
}
symbols := make([]string, len(watch))
for i, w := range watch {
symbols[i] = w.Symbol
}
prices, err := BinanceClient.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
}
parts := make([]string, 0, len(watch))
for _, w := range watch {
if px, ok := priceBySymbol[w.Symbol]; ok {
parts = append(parts, fmt.Sprintf("%s=%.8f", w.Symbol, px))
} else {
parts = append(parts, w.Symbol+"=?")
}
}
log.Printf("logic: 现货轮询现价 %s", strings.Join(parts, ", "))
portfolioMu.Lock()
defer portfolioMu.Unlock()
spotTickDirty = false
for _, w := range watch {
px, ok := priceBySymbol[w.Symbol]
if !ok {
continue
}
if err := processOne(ctx, BinanceClient, acct.Balances, w, px); err != nil {
log.Printf("logic: %s 处理失败: %v", w.Symbol, err)
}
}
if !spotTickDirty {
return nil
}
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
}
// spotHoldingThresholdUSDT 判断是否视为「有仓」的名义市值下限USDT
// 若仅用固定 minHoldUSDT而配置 OrderQtyUsdt 与 10U 接近LOT 向下取整后实际成交名义略小于配置,
// 会被误判为空仓并重复首仓;对在全局门槛附近的单笔配置名义降低阈值并留松弛。
func spotHoldingThresholdUSDT(w spotSymbol, price float64) float64 {
t := minHoldUSDT
nominal := w.OrderQtyUsdt
if nominal >= minHoldUSDT*0.85 && nominal <= minHoldUSDT*1.25 {
relaxed := nominal * 0.965
if relaxed < t {
t = relaxed
}
}
if t < 0.3 {
t = 0.3
}
return t
}
// 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
holdTh := spotHoldingThresholdUSDT(w, price)
if positionUSDT >= holdTh {
st.OpenReboundLow = 0
}
if positionUSDT < holdTh {
st.DipAddsDone = 0
st.DipLegLocked = false
st.RallyLegLocked = false
resetSpotTrail(st)
neverEntered := st.AvgCostUSDT <= 0 && st.Quantity <= 0
ready := (spotImmediateInitialOpen && neverEntered) || spotReboundReady(&st.OpenReboundLow, price)
if !ready {
log.Printf("logic: %s 空仓 现价=%.8f 阶段低=%.8f 待反弹≥%.3f%% 才首仓 (持仓≈%.2f USDT)", w.Symbol, price, st.OpenReboundLow, buyReboundPct*100, positionUSDT)
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)
}