This commit is contained in:
2026-05-07 09:56:21 +08:00
parent 12ffcbef6a
commit 38c579145f
12 changed files with 560 additions and 222 deletions

View File

@@ -1,19 +1,17 @@
// Package logic 提供 Binance 现货策略;本文件为定时轮询(非 WebSocket的建仓、超跌加仓与冲高减仓
// Package logic 提供 Binance 现货策略;本文件为定时轮询(非 WebSocket的建仓、超跌加仓与跟踪止盈全平
package logic
import (
"context"
"errors"
"log"
"strconv"
"strings"
"sync"
"time"
"git.apinb.com/quant/coin/internal/config"
"git.apinb.com/quant/coin/internal/impl"
"git.apinb.com/quant/coin/internal/models"
"github.com/adshao/go-binance/v2"
"github.com/shopspring/decimal"
)
// 以下为可调参数,改数值即可调整策略行为(改后重新编译运行)。
@@ -21,40 +19,61 @@ const (
// pollInterval 两次完整轮询之间的间隔(拉余额、价格、判单、写库)
pollInterval = 60 * time.Second
// buyQuoteUSDT 每次市价买入使用的报价资产数量USDT
buyQuoteUSDT = 100.0
// dipPct 相对成本价的下跌比例,跌破则触发加仓(若本波未锁)
dipPct = 0.10
// profitPct 相对成本价的上涨比例,涨破则触发减仓(若本波未锁)
profitPct = 0.10
// sellFraction 上涨触发时,卖出当前可用基础资产的比例
sellFraction = 0.20
// maxDipAdds 相对当前均价最多加仓次数(不含首仓
maxDipAdds = 4
// profitArmPct 浮盈达到该比例后进入「跟踪高点」:继续上涨不平仓,仅从高点回撤 trailPullbackPct 时全平
profitArmPct = 0.05
// trailPullbackPct 从跟踪期内最高价回撤本比例则触发市价全仓卖出
trailPullbackPct = 0.005
// minHoldUSDT 持仓市值低于此值USDT视为「无仓」先建首笔 100U
// minHoldUSDT 持仓市值低于此值USDT视为「无仓」按配置 OrderQty 市价买基础币建首笔
minHoldUSDT = 10.0
// minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional
minSellNotional = 10.0
// dipRecoverPct 超跌加仓后,现价需高于「成本×(1+本值)」才解除加仓锁,避免同一低位反复买
dipRecoverPct = 0.03
// rallyResetHighPct 冲高减仓后,现价需低于「成本×(1+本值)」才解除减仓锁,避免同一高位反复卖
rallyResetHighPct = 0.06
// buyReboundPct 开仓/加仓前须相对阶段低点反弹超过本比例才市价买入(持续创新低则不买)
buyReboundPct = 0.0039
)
// spotSymbol 把「账户里的资产名」和「交易对」绑在一起,避免硬编码散落各处
// spotSymbol 单标的运行时视图:基础资产、交易对、每笔买入的基础币数量(来自配置 SpotWatchList
type spotSymbol struct {
Base string // 如 BTC
Symbol string // 如 BTCUSDT
Base string
Symbol string // 如 BTCUSDT
OrderQty float64 // 开仓与加仓共用(枚)
}
// watchList 策略监控的标的列表;增删币种时改此处即可
var watchList = []spotSymbol{
{"BTC", "BTCUSDT"},
{"ETH", "ETHUSDT"},
{"SOL", "SOLUSDT"},
{"XRP", "XRPUSDT"},
// spotWatchesFromConfig 从 Spec.SpotWatchList 解析有效项Symbol 空或 OrderQty≤0 的条目会被跳过
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.OrderQty <= 0 {
continue
}
us := strings.ToUpper(sym)
out = append(out, spotSymbol{
Base: spotBaseFromSymbol(us),
Symbol: us,
OrderQty: it.OrderQty,
})
}
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 (
portfolioMu sync.Mutex // 保护 portfolio 与数据库写入,避免并发轮询(若以后拆协程)
portfolio = models.NewSpotPortfolioSnapshot()
@@ -86,6 +105,10 @@ func runBinanceSpotStrategy() {
log.Printf("logic: Binance 账户未开启现货交易权限")
return
}
if len(spotWatchesFromConfig()) == 0 {
log.Printf("logic: SpotWatchList 未配置或无效,跳过现货策略")
return
}
log.Printf("logic: Binance 已连接CanTrade=%v", acct.CanTrade)
if err := loadPortfolio(); err != nil {
@@ -105,82 +128,20 @@ func runBinanceSpotStrategy() {
}
}
// refreshStepSizes 从 exchangeInfo 拉取各 symbol 的 LOT_SIZE.stepSize供卖出数量格式化
func refreshStepSizes(ctx context.Context, client *binance.Client) error {
syms := make([]string, 0, len(watchList))
for _, w := range watchList {
syms = append(syms, w.Symbol)
}
info, err := client.NewExchangeInfoService().Symbols(syms...).Do(ctx)
if err != nil {
return err
}
for _, s := range info.Symbols {
lot := s.LotSizeFilter()
if lot == nil || lot.StepSize == "" {
continue
}
stepSizes[s.Symbol] = lot.StepSize
}
return nil
}
// loadPortfolio 从 GORM 读入全表 spot_positions填充内存 map键为 BaseAsset
func loadPortfolio() error {
portfolioMu.Lock()
defer portfolioMu.Unlock()
if impl.DBService == nil {
portfolio = models.NewSpotPortfolioSnapshot()
return nil
}
var rows []models.SpotPosition
if err := impl.DBService.Find(&rows).Error; err != nil {
return err
}
portfolio = models.NewSpotPortfolioSnapshot()
for i := range rows {
p := new(models.SpotPosition)
*p = rows[i]
portfolio.Positions[p.BaseAsset] = p
}
return nil
}
// savePortfolioLocked 将内存中各 SpotPosition 以 Save 写回数据库(有主键则更新,无则插入)。
// 调用方必须已持有 portfolioMu。
func savePortfolioLocked() error {
if impl.DBService == nil {
return nil
}
for _, st := range portfolio.Positions {
if st == nil {
continue
}
if err := impl.DBService.Save(st).Error; err != nil {
return err
}
}
return nil
}
// balanceFree 从账户余额列表里解析某资产的可用数量Free 字段为字符串)。
func balanceFree(balances []binance.Balance, asset string) (float64, error) {
for _, b := range balances {
if b.Asset == asset {
return strconv.ParseFloat(b.Free, 64)
}
}
return 0, nil
}
// spotTick 单次轮询:拉账户 + 批量现价 → 对每个 watchList 标的执行 processOne → 持久化到库。
// spotTick 单次轮询:拉账户 + 批量现价 → 对每个配置标的执行 processOne → 持久化到库
func spotTick(ctx context.Context, client *binance.Client) error {
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
watch := spotWatchesFromConfig()
if len(watch) == 0 {
return nil
}
acct, err := client.NewGetAccountService().Do(ctx)
if err != nil {
return err
}
symbols := make([]string, len(watchList))
for i, w := range watchList {
symbols := make([]string, len(watch))
for i, w := range watch {
symbols[i] = w.Symbol
}
prices, err := client.NewListPricesService().Symbols(symbols).Do(ctx)
@@ -199,7 +160,7 @@ func spotTick(ctx context.Context, client *binance.Client) error {
portfolioMu.Lock()
defer portfolioMu.Unlock()
for _, w := range watchList {
for _, w := range watch {
px, ok := priceBySymbol[w.Symbol]
if !ok {
continue
@@ -233,147 +194,60 @@ func processOne(ctx context.Context, client *binance.Client, balances []binance.
st := getOrCreateState(w.Base, w.Symbol)
positionUSDT := free * price
// ---------- 分支一:视为空仓,首笔市价买 100 USDT ----------
if positionUSDT < minHoldUSDT {
st.DipLegLocked = false
st.RallyLegLocked = false
usdtFree, err := balanceFree(balances, "USDT")
if err != nil {
return err
}
if usdtFree < buyQuoteUSDT {
return errors.New("USDT 余额不足,无法建仓 100U")
}
order, err := client.NewCreateOrderService().
Symbol(w.Symbol).
Side(binance.SideTypeBuy).
Type(binance.OrderTypeMarket).
QuoteOrderQty(strconv.FormatFloat(buyQuoteUSDT, 'f', 2, 64)).
NewOrderRespType(binance.NewOrderRespTypeFULL).
Do(ctx)
if err != nil {
return err
}
applyBuyFill(st, order)
log.Printf("logic: %s 初始建仓约 %.2f USDT, 成交均价约 %.4f", w.Symbol, buyQuoteUSDT, st.AvgCostUSDT)
return nil
if positionUSDT >= minHoldUSDT {
st.OpenReboundLow = 0
}
if positionUSDT < minHoldUSDT {
st.DipAddsDone = 0
st.DipLegLocked = false
st.RallyLegLocked = false
resetSpotTrail(st)
if !spotReboundReady(&st.OpenReboundLow, price) {
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
// ---------- 分支二:相对成本下跌 dipPct且本波未加仓过 → 再买 100 USDT ----------
dipLine := st.AvgCostUSDT * (1 - dipPct)
if price <= dipLine && !st.DipLegLocked {
usdtFree, err := balanceFree(balances, "USDT")
if err != nil {
return err
}
if usdtFree < buyQuoteUSDT {
return errors.New("USDT 余额不足,无法加仓 100U")
}
order, err := client.NewCreateOrderService().
Symbol(w.Symbol).
Side(binance.SideTypeBuy).
Type(binance.OrderTypeMarket).
QuoteOrderQty(strconv.FormatFloat(buyQuoteUSDT, 'f', 2, 64)).
NewOrderRespType(binance.NewOrderRespTypeFULL).
Do(ctx)
if err != nil {
return err
}
applyBuyFill(st, order)
st.DipLegLocked = true
log.Printf("logic: %s 下跌 %.0f%% 加仓 %.0f USDT, 新成本约 %.4f", w.Symbol, dipPct*100, buyQuoteUSDT, st.AvgCostUSDT)
if err := trySpotDipAdd(ctx, client, balances, w, price, st); err != nil {
return err
}
// 价格明显站回成本上方后,允许下一次「超跌加仓」
if st.DipLegLocked && price >= st.AvgCostUSDT*(1+dipRecoverPct) {
st.DipLegLocked = false
}
// ---------- 分支三:相对成本上涨 profitPct且本波未减仓过 → 卖出 free 的 sellFraction ----------
profitLine := st.AvgCostUSDT * (1 + profitPct)
if price >= profitLine && !st.RallyLegLocked {
step := stepSizes[w.Symbol]
if step == "" {
step = "0.00001"
}
qtyStr, ok, err := formatQtyForSell(free*sellFraction, step)
if err != nil {
return err
}
if !ok || parseFloat(qtyStr)*price < minSellNotional {
return nil
}
order, err := client.NewCreateOrderService().
Symbol(w.Symbol).
Side(binance.SideTypeSell).
Type(binance.OrderTypeMarket).
Quantity(qtyStr).
NewOrderRespType(binance.NewOrderRespTypeFULL).
Do(ctx)
if err != nil {
return err
}
sold, _ := strconv.ParseFloat(order.ExecutedQuantity, 64)
st.Quantity = free - sold
if st.Quantity < 0 {
st.Quantity = 0
}
st.RallyLegLocked = true
log.Printf("logic: %s 上涨 %.0f%% 减持 %.0f%%, 卖出数量 %s", w.Symbol, profitPct*100, sellFraction*100, qtyStr)
}
// 从高位回落靠近成本后,允许下一次「冲高减仓」
if st.RallyLegLocked && price <= st.AvgCostUSDT*(1+rallyResetHighPct) {
st.RallyLegLocked = false
if err := trySpotRallySell(ctx, client, w, price, st, free); err != nil {
return err
}
return nil
}
// applyBuyFill 根据市价买单成交回报更新加权成本与数量(首笔建仓与加仓共用)。
func applyBuyFill(st *models.SpotPosition, order *binance.CreateOrderResponse) {
execQty, _ := strconv.ParseFloat(order.ExecutedQuantity, 64)
quote, _ := strconv.ParseFloat(order.CummulativeQuoteQuantity, 64)
if execQty <= 0 || quote <= 0 {
return
}
oldQty := st.Quantity
oldCost := st.AvgCostUSDT
newQty := oldQty + execQty
if newQty <= 0 {
return
}
if oldQty <= 0 || oldCost <= 0 {
st.AvgCostUSDT = quote / execQty
} else {
st.AvgCostUSDT = (oldQty*oldCost + quote) / newQty
}
st.Quantity = newQty
// resetSpotTrail 清除跟踪止盈状态(空仓、加仓后成本变化时调用)。
func resetSpotTrail(st *models.SpotPosition) {
st.TrailArmed = false
st.TrailPeakUSDT = 0
}
// formatQtyForSell 将「计划卖出数量」按 stepSize 向下取整,满足 Binance LOT_SIZE过小则返回 ok=false。
func formatQtyForSell(qty float64, stepSize string) (string, bool, error) {
if qty <= 0 {
return "", false, nil
// 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
}
step, err := decimal.NewFromString(stepSize)
if err != nil {
return "", false, err
if price < *latch {
*latch = price
return false
}
q := decimal.NewFromFloat(qty)
n := q.Div(step).Floor()
out := n.Mul(step)
if out.LessThanOrEqual(decimal.Zero) {
return "", false, nil
}
return out.String(), true, nil
}
func parseFloat(s string) float64 {
v, _ := strconv.ParseFloat(s, 64)
return v
return price >= *latch*(1+buyReboundPct)
}