diff --git a/etc/coin_dev.yaml b/etc/coin_dev.yaml index fb1a75b..e306090 100644 --- a/etc/coin_dev.yaml +++ b/etc/coin_dev.yaml @@ -18,9 +18,7 @@ BinanceApiSecret: "YqTpRybnBWllS0fA1yk0T1MEx0RxRazc2bH2iZuPEI8QJKesUueq3saCDdDj7 # 现货轮询标的:Symbol 为交易对,OrderQty 为每笔开仓/加仓买入的基础币数量(列表为空则不调现货策略) SpotWatchList: - Symbol: BTCUSDT - OrderQty: 0.001 - - Symbol: ETHUSDT - OrderQty: 0.01 + OrderQty: 0.00001 # 日志配置 Log: diff --git a/etc/coin_prod.yaml b/etc/coin_prod.yaml index 07caf5b..9eaa202 100644 --- a/etc/coin_prod.yaml +++ b/etc/coin_prod.yaml @@ -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: diff --git a/etc/coin_test.yaml b/etc/coin_test.yaml index 07caf5b..e306090 100644 --- a/etc/coin_test.yaml +++ b/etc/coin_test.yaml @@ -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: diff --git a/internal/logic/spot_binance.go b/internal/logic/spot_binance.go index 3746cda..4c0e830 100644 --- a/internal/logic/spot_binance.go +++ b/internal/logic/spot_binance.go @@ -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) diff --git a/internal/logic/spot_binance_account.go b/internal/logic/spot_binance_account.go index 469267e..07a10a9 100644 --- a/internal/logic/spot_binance_account.go +++ b/internal/logic/spot_binance_account.go @@ -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 +} diff --git a/internal/logic/spot_binance_close.go b/internal/logic/spot_binance_close.go index 62d8077..6cee8b7 100644 --- a/internal/logic/spot_binance_close.go +++ b/internal/logic/spot_binance_close.go @@ -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 步长取整为 0(free=%.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 } diff --git a/internal/logic/spot_binance_open.go b/internal/logic/spot_binance_open.go index bec5f04..c51c04d 100644 --- a/internal/logic/spot_binance_open.go +++ b/internal/logic/spot_binance_open.go @@ -118,4 +118,5 @@ func applyBuyFill(st *models.SpotPosition, order *binance.CreateOrderResponse) { st.AvgCostUSDT = (oldQty*oldCost + quote) / newQty } st.Quantity = newQty + markSpotPortfolioDirty() } diff --git a/scripts/build.sh b/scripts/build.sh index 2043caf..780a15b 100644 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -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 \ No newline at end of file