Files
coin/internal/logic/spot_binance.go

256 lines
8.4 KiB
Go
Raw Normal View History

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