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

@@ -18,9 +18,7 @@ BinanceApiSecret: "YqTpRybnBWllS0fA1yk0T1MEx0RxRazc2bH2iZuPEI8QJKesUueq3saCDdDj7
# 现货轮询标的Symbol 为交易对OrderQty 为每笔开仓/加仓买入的基础币数量(列表为空则不调现货策略)
SpotWatchList:
- Symbol: BTCUSDT
OrderQty: 0.001
- Symbol: ETHUSDT
OrderQty: 0.01
OrderQty: 0.00001
# 日志配置
Log:

View File

@@ -15,11 +15,10 @@ Cache: redis://null:Weidong2023~!@139.224.247.176:19379/1
BinanceApiKey: "qZrLXzykvb7w7uKVJ45rXomp94BiJnk4a4tsaduAHKMYlljVcwWsZzOA3pYdGnqo"
BinanceApiSecret: "YqTpRybnBWllS0fA1yk0T1MEx0RxRazc2bH2iZuPEI8QJKesUueq3saCDdDj7hpU"
# 现货轮询标的Symbol 为交易对OrderQty 为每笔开仓/加仓买入的基础币数量(列表为空则不调现货策略)
SpotWatchList:
- Symbol: BTCUSDT
OrderQty: 0.001
- Symbol: ETHUSDT
OrderQty: 0.01
OrderQty: 0.0001
# 日志配置
Log:

View File

@@ -15,11 +15,10 @@ Cache: redis://null:Weidong2023~!@139.224.247.176:19379/1
BinanceApiKey: "qZrLXzykvb7w7uKVJ45rXomp94BiJnk4a4tsaduAHKMYlljVcwWsZzOA3pYdGnqo"
BinanceApiSecret: "YqTpRybnBWllS0fA1yk0T1MEx0RxRazc2bH2iZuPEI8QJKesUueq3saCDdDj7hpU"
# 现货轮询标的Symbol 为交易对OrderQty 为每笔开仓/加仓买入的基础币数量(列表为空则不调现货策略)
SpotWatchList:
- Symbol: BTCUSDT
OrderQty: 0.001
- Symbol: ETHUSDT
OrderQty: 0.01
OrderQty: 0.00001
# 日志配置
Log:

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)

View File

@@ -2,14 +2,24 @@ package logic
import (
"context"
"log"
"strconv"
"time"
"git.apinb.com/quant/coin/internal/impl"
"git.apinb.com/quant/coin/internal/models"
"github.com/adshao/go-binance/v2"
)
// refreshStepSizes 从 exchangeInfo 拉取各 symbol 的 LOT_SIZE.stepSize供卖出数量格式化
// logSpotWatchConfigOnly 在 exchangeInfo 拉取失败时仍打印 yaml 中的标的与 OrderQty
func logSpotWatchConfigOnly() {
for _, w := range spotWatchesFromConfig() {
log.Printf("logic: 现货标的(仅配置,未取到交易所规则) %s 每笔OrderQty=%.8f", w.Symbol, w.OrderQty)
}
}
// refreshStepSizes 从 exchangeInfo 拉取各 symbol 的 LOT_SIZE.stepSize供卖出数量格式化
// 成功后按标的打印配置 OrderQty 与交易所最小量、步长、最小名义等。
func refreshStepSizes(ctx context.Context, client *binance.Client) error {
watch := spotWatchesFromConfig()
syms := make([]string, 0, len(watch))
@@ -20,12 +30,42 @@ func refreshStepSizes(ctx context.Context, client *binance.Client) error {
if err != nil {
return err
}
for _, s := range info.Symbols {
bySymbol := make(map[string]binance.Symbol, len(info.Symbols))
for i := range info.Symbols {
s := info.Symbols[i]
bySymbol[s.Symbol] = s
lot := s.LotSizeFilter()
if lot == nil || lot.StepSize == "" {
if lot != nil && lot.StepSize != "" {
stepSizes[s.Symbol] = lot.StepSize
}
}
log.Printf("logic: 现货 SpotWatchList 与交易所最小交易量exchangeInfo")
for _, w := range watch {
minQty, step := "-", "-"
marketMin := "-"
minNotional := "-"
s, ok := bySymbol[w.Symbol]
if !ok {
log.Printf("logic: %s 配置OrderQty=%.8f | 交易所未返回该交易对规则", w.Symbol, w.OrderQty)
continue
}
stepSizes[s.Symbol] = lot.StepSize
if lot := s.LotSizeFilter(); lot != nil {
if lot.MinQuantity != "" {
minQty = lot.MinQuantity
}
if lot.StepSize != "" {
step = lot.StepSize
}
}
if mls := s.MarketLotSizeFilter(); mls != nil && mls.MinQuantity != "" {
marketMin = mls.MinQuantity
}
if nf := s.NotionalFilter(); nf != nil && nf.MinNotional != "" {
minNotional = nf.MinNotional
}
log.Printf("logic: %s 配置OrderQty=%.8f | LOT最小量=%s 步长=%s | 市价单最小基础量=%s | minNotional=%s",
w.Symbol, w.OrderQty, minQty, step, marketMin, minNotional)
}
return nil
}
@@ -77,3 +117,93 @@ func balanceFree(balances []binance.Balance, asset string) (float64, error) {
}
return 0, nil
}
// balanceLocked 解析某资产下单冻结数量Locked
func balanceLocked(balances []binance.Balance, asset string) float64 {
for _, b := range balances {
if b.Asset == asset {
v, err := strconv.ParseFloat(b.Locked, 64)
if err != nil {
return 0
}
return v
}
}
return 0
}
// startupSyncSpotHoldingFromBinance 启动时用已拉取的账户余额与 ListPrices 现价,按 SpotWatchList 将实际可用持仓与空仓/有仓状态对齐到内存并写库(与 processOne 一致以 Free 为可交易数量)。
func startupSyncSpotHoldingFromBinance(ctx context.Context, client *binance.Client, balances []binance.Balance) error {
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
watch := spotWatchesFromConfig()
if len(watch) == 0 {
return nil
}
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 {
log.Printf("logic: 启动同步持仓 %s 未取到现价,跳过该标的", w.Symbol)
continue
}
st := getOrCreateState(w.Base, w.Symbol)
st.Symbol = w.Symbol
free, err := balanceFree(balances, w.Base)
if err != nil {
return err
}
locked := balanceLocked(balances, w.Base)
st.Quantity = free
positionUSDT := free * px
holdTh := spotHoldingThresholdUSDT(w, px)
if positionUSDT < holdTh {
st.DipAddsDone = 0
st.DipLegLocked = false
st.RallyLegLocked = false
resetSpotTrail(st)
st.OpenReboundLow = 0
st.DipReboundLow = 0
st.AvgCostUSDT = 0
log.Printf("logic: 启动持仓同步 %s 可用=%.8f 冻结=%.8f 名义≈%.4f USDT<%.4f USDT 视为空仓)已对齐库", w.Symbol, free, locked, positionUSDT, holdTh)
continue
}
st.OpenReboundLow = 0
if st.AvgCostUSDT <= 0 {
st.AvgCostUSDT = px
}
log.Printf("logic: 启动持仓同步 %s 可用=%.8f 冻结=%.8f 名义≈%.4f USDT≥%.4f USDT 有仓)数量与成本已对齐库", w.Symbol, free, locked, positionUSDT, holdTh)
}
if impl.DBService == nil {
log.Printf("logic: 启动持仓同步完成(未配置数据库,仅内存)")
return nil
}
if err := savePortfolioLocked(); err != nil {
return err
}
log.Printf("logic: 启动持仓同步已写入数据库")
return nil
}

View File

@@ -39,9 +39,16 @@ func trySpotRallySell(ctx context.Context, client *binance.Client, w spotSymbol,
if err != nil {
return err
}
if !ok || parseFloat(qtyStr)*price < minSellNotional {
if !ok {
log.Printf("logic: %s 跟踪止盈回撤触发但可卖数量按 LOT 步长取整为 0free=%.12f解除跟踪dust 请手工或下轮余额变化后再管", w.Symbol, free)
resetSpotTrail(st)
markSpotPortfolioDirty()
return nil
}
nominal := parseFloat(qtyStr) * price
if nominal < minSellNotional {
log.Printf("logic: %s 跟踪止盈:卖出名义约 %.4f USDT 低于参考门槛 %.2f,仍尝试市价可卖尽卖", w.Symbol, nominal, minSellNotional)
}
order, err := client.NewCreateOrderService().
Symbol(w.Symbol).
Side(binance.SideTypeSell).
@@ -59,6 +66,7 @@ func trySpotRallySell(ctx context.Context, client *binance.Client, w spotSymbol,
}
peak := st.TrailPeakUSDT
resetSpotTrail(st)
markSpotPortfolioDirty()
log.Printf("logic: %s 从跟踪高点 %.6f 回撤≥%.1f%% 全平, 卖出数量 %s", w.Symbol, peak, trailPullbackPct*100, qtyStr)
return nil
}

View File

@@ -118,4 +118,5 @@ func applyBuyFill(st *models.SpotPosition, order *binance.CreateOrderResponse) {
st.AvgCostUSDT = (oldQty*oldCost + quote) / newQty
}
st.Quantity = newQty
markSpotPortfolioDirty()
}

View File

@@ -4,3 +4,5 @@ GOARCH=amd64 GOOS=linux go build -o ../builds/coin ./cmd/main/main.go
BSM_RuntimeMode=prod BSM_Prefix=/data/app/ nohup ./coin > /data/app/logs/coin.log 2>&1 &
cat /data/app/logs/coin.log
BSM_RuntimeMode=prod BSM_Prefix=/data/app/ ./coin