This commit is contained in:
2026-05-08 23:37:34 +08:00
parent 7bfd09d3bc
commit 00dac88290
8 changed files with 206 additions and 18 deletions

View File

@@ -3,6 +3,7 @@ package logic
import (
"context"
"fmt"
"log"
"strconv"
"strings"
@@ -26,7 +27,7 @@ const (
// trailPullbackPct 从跟踪期内最高价回撤本比例则触发市价全仓卖出
trailPullbackPct = 0.005
// minHoldUSDT 持仓市值低于此值USDT视为「无仓」会按配置 OrderQty 市价买基础币建首笔
// minHoldUSDT 持仓市值默认「无仓」判断的全局名义下限USDT单笔 OrderQty×价 接近该值时会用 spotHoldingThresholdUSDT 放宽,避免 LOT 取整后略低于本值被误判空仓。
minHoldUSDT = 10.0
// minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional
minSellNotional = 10.0
@@ -35,6 +36,10 @@ const (
dipRecoverPct = 0.03
// buyReboundPct 开仓/加仓前须相对阶段低点反弹超过本比例才市价买入(持续创新低则不买)
buyReboundPct = 0.0039
// spotImmediateInitialOpen 为 true 时,仅当策略侧从未有过持仓(成本与数量均为 0时跳过「先锚定再等反弹」
// 启动后首轮即可市价开首仓;曾经建仓后又全平的标的仍须等反弹后再进,避免刚卖立刻买回。
spotImmediateInitialOpen = true
)
// spotSymbol 单标的运行时视图:基础资产、交易对、每笔买入的基础币数量(来自配置 SpotWatchList
@@ -79,8 +84,15 @@ var (
portfolio = models.NewSpotPortfolioSnapshot()
// stepSizes 各交易对 LOT_SIZE 的 stepSize用于卖出数量按交易所步长向下取整
stepSizes = map[string]string{}
// spotTickDirty 本轮是否发生过市价成交(首仓/加仓/全平)并改动了需持久化的字段;无成交则不写库。
// 无成交时的 trail/反弹锚点等仅驻内存,进程异常退出可能丢失该段进度(下轮仍可从账户与价格继续推演)。
spotTickDirty bool
)
func markSpotPortfolioDirty() {
spotTickDirty = true
}
// runBinanceSpotStrategy 入口:校验配置 → 连通性 → 死循环定时执行 spotTick。
// 若 Key/Secret 为空或 API 失败会直接 return不再占用协程由 Boot 调用方决定是否在 goroutine 里跑)。
func runBinanceSpotStrategy() {
@@ -114,8 +126,12 @@ func runBinanceSpotStrategy() {
if err := loadPortfolio(); err != nil {
log.Printf("logic: 从数据库加载现货持仓失败(将使用空档): %v", err)
}
if err := startupSyncSpotHoldingFromBinance(ctx, client, acct.Balances); err != nil {
log.Printf("logic: 启动时与交易所同步持仓失败: %v", err)
}
if err := refreshStepSizes(ctx, client); err != nil {
log.Printf("logic: 加载交易对精度失败: %v", err)
logSpotWatchConfigOnly()
}
ticker := time.NewTicker(pollInterval)
@@ -156,10 +172,20 @@ func spotTick(ctx context.Context, client *binance.Client) error {
}
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 {
@@ -169,6 +195,9 @@ func spotTick(ctx context.Context, client *binance.Client) error {
log.Printf("logic: %s 处理失败: %v", w.Symbol, err)
}
}
if !spotTickDirty {
return nil
}
return savePortfolioLocked()
}
@@ -185,6 +214,24 @@ func getOrCreateState(base, symbol string) *models.SpotPosition {
return st
}
// spotHoldingThresholdUSDT 判断是否视为「有仓」的名义市值下限USDT
// 若仅用固定 minHoldUSDT而配置 OrderQty×现价 与 10U 接近LOT 向下取整后实际成交量略小(如 0.0001→0.0000999
// 名义可能略低于 10U会被误判为空仓并重复首仓对在全局门槛附近的单笔配置名义降低阈值并留松弛。
func spotHoldingThresholdUSDT(w spotSymbol, price float64) float64 {
t := minHoldUSDT
nominal := w.OrderQty * price
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)
@@ -193,17 +240,21 @@ func processOne(ctx context.Context, client *binance.Client, balances []binance.
}
st := getOrCreateState(w.Base, w.Symbol)
positionUSDT := free * price
holdTh := spotHoldingThresholdUSDT(w, price)
if positionUSDT >= minHoldUSDT {
if positionUSDT >= holdTh {
st.OpenReboundLow = 0
}
if positionUSDT < minHoldUSDT {
if positionUSDT < holdTh {
st.DipAddsDone = 0
st.DipLegLocked = false
st.RallyLegLocked = false
resetSpotTrail(st)
if !spotReboundReady(&st.OpenReboundLow, price) {
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)