diff --git a/README.md b/README.md index 892f7bd..b82141d 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ | `BinanceApiKey` / `BinanceApiSecret` | 现货 API;需在 Binance 开启**现货交易**权限 | | `SpotWatchList` | 标的列表;为空则跳过整个现货策略 | | `SpotWatchList[].Symbol` | 交易对,如 `BTCUSDT` | -| `SpotWatchList[].OrderQty` | 每次**开仓**与**加仓**买入的**基础币数量**(按 LOT_SIZE 向下取整) | +| `SpotWatchList[].OrderQtyUsdt` | 每次**开仓**与**加仓**买入的 **USDT 名义**(须 ≥ 10,按现价换基础币量并按 LOT_SIZE 向下取整) | | `Databases` / `Cache` | 由 `impl.NewImpl` 初始化,供迁移与其它模块使用 | ## 策略逻辑(按执行顺序) @@ -28,7 +28,7 @@ - 重置:`DipAddsDone`、`DipLegLocked`、跟踪止盈状态、`OpenReboundLow` 等。 - **反弹过滤**:用 `OpenReboundLow` 记录阶段低价;价格**持续创新低**时不买;现价相对该低点**反弹 ≥ `buyReboundPct`(0.39%)** 后才允许首买。 -- 按 `OrderQty` 取整后下**市价买单**(`Quantity`),更新加权成本与数量。 +- 按 `OrderQtyUsdt` 与现价换算数量、取整后下**市价买单**(`Quantity`),更新加权成本与数量。 ### 2. 有仓 diff --git a/etc/coin_dev.yaml b/etc/coin_dev.yaml index e306090..5b96aa5 100644 --- a/etc/coin_dev.yaml +++ b/etc/coin_dev.yaml @@ -15,10 +15,10 @@ Cache: redis://null:Weidong2023~!@139.224.247.176:19379/1 BinanceApiKey: "qZrLXzykvb7w7uKVJ45rXomp94BiJnk4a4tsaduAHKMYlljVcwWsZzOA3pYdGnqo" BinanceApiSecret: "YqTpRybnBWllS0fA1yk0T1MEx0RxRazc2bH2iZuPEI8QJKesUueq3saCDdDj7hpU" -# 现货轮询标的:Symbol 为交易对,OrderQty 为每笔开仓/加仓买入的基础币数量(列表为空则不调现货策略) +# 现货轮询标的:Symbol 为交易对,OrderQtyUsdt 为每笔开仓/加仓买入的 USDT 名义,须 ≥10(列表为空则不调现货策略) SpotWatchList: - Symbol: BTCUSDT - OrderQty: 0.00001 + OrderQtyUsdt: 10 # 日志配置 Log: diff --git a/etc/coin_prod.yaml b/etc/coin_prod.yaml index 9eaa202..5b96aa5 100644 --- a/etc/coin_prod.yaml +++ b/etc/coin_prod.yaml @@ -15,10 +15,10 @@ Cache: redis://null:Weidong2023~!@139.224.247.176:19379/1 BinanceApiKey: "qZrLXzykvb7w7uKVJ45rXomp94BiJnk4a4tsaduAHKMYlljVcwWsZzOA3pYdGnqo" BinanceApiSecret: "YqTpRybnBWllS0fA1yk0T1MEx0RxRazc2bH2iZuPEI8QJKesUueq3saCDdDj7hpU" -# 现货轮询标的:Symbol 为交易对,OrderQty 为每笔开仓/加仓买入的基础币数量(列表为空则不调现货策略) +# 现货轮询标的:Symbol 为交易对,OrderQtyUsdt 为每笔开仓/加仓买入的 USDT 名义,须 ≥10(列表为空则不调现货策略) SpotWatchList: - Symbol: BTCUSDT - OrderQty: 0.0001 + OrderQtyUsdt: 10 # 日志配置 Log: diff --git a/etc/coin_test.yaml b/etc/coin_test.yaml index e306090..5b96aa5 100644 --- a/etc/coin_test.yaml +++ b/etc/coin_test.yaml @@ -15,10 +15,10 @@ Cache: redis://null:Weidong2023~!@139.224.247.176:19379/1 BinanceApiKey: "qZrLXzykvb7w7uKVJ45rXomp94BiJnk4a4tsaduAHKMYlljVcwWsZzOA3pYdGnqo" BinanceApiSecret: "YqTpRybnBWllS0fA1yk0T1MEx0RxRazc2bH2iZuPEI8QJKesUueq3saCDdDj7hpU" -# 现货轮询标的:Symbol 为交易对,OrderQty 为每笔开仓/加仓买入的基础币数量(列表为空则不调现货策略) +# 现货轮询标的:Symbol 为交易对,OrderQtyUsdt 为每笔开仓/加仓买入的 USDT 名义,须 ≥10(列表为空则不调现货策略) SpotWatchList: - Symbol: BTCUSDT - OrderQty: 0.00001 + OrderQtyUsdt: 10 # 日志配置 Log: diff --git a/internal/config/config.go b/internal/config/config.go index e82a5b3..4e631bf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,19 +11,19 @@ var ( Spec SrvConfig ) -// SpotWatchItem 现货策略单标的:交易对标识 + 每笔市价买入的基础资产数量(开仓与超跌加仓相同)。 +// SpotWatchItem 现货策略单标的:交易对标识 + 每笔市价买入的报价资产名义(USDT,开仓与超跌加仓相同)。 type SpotWatchItem struct { - Symbol string `yaml:"Symbol"` // 如 BTCUSDT - OrderQty float64 `yaml:"OrderQty"` // 每次开仓/加仓买入的基础币数量,须 > 0,会按交易所 LOT_SIZE 向下取整 + Symbol string `yaml:"Symbol"` // 如 BTCUSDT + OrderQtyUsdt float64 `yaml:"OrderQtyUsdt"` // 每次开仓/加仓买入的 USDT 名义,须 ≥ 10,会按现价换基础币量并按 LOT_SIZE 向下取整 } type SrvConfig struct { - conf.Base `yaml:",inline"` - ApiPort string `yaml:"ApiPort"` - WebPort string `yaml:"WebPort"` - Databases *conf.DBConf `yaml:"Databases"` - BinanceApiKey string `yaml:"BinanceApiKey"` - BinanceApiSecret string `yaml:"BinanceApiSecret"` + conf.Base `yaml:",inline"` + ApiPort string `yaml:"ApiPort"` + WebPort string `yaml:"WebPort"` + Databases *conf.DBConf `yaml:"Databases"` + BinanceApiKey string `yaml:"BinanceApiKey"` + BinanceApiSecret string `yaml:"BinanceApiSecret"` // SpotWatchList 现货轮询标的;为空则跳过现货策略 SpotWatchList []SpotWatchItem `yaml:"SpotWatchList"` } diff --git a/internal/logic/boot.go b/internal/logic/boot.go index 0519209..4cfed34 100644 --- a/internal/logic/boot.go +++ b/internal/logic/boot.go @@ -1,6 +1,163 @@ package logic +import ( + "context" + "errors" + "log" + "strings" + "sync" + "time" + + "git.apinb.com/quant/coin/internal/config" + "git.apinb.com/quant/coin/internal/models" + "github.com/adshao/go-binance/v2" +) + +var ( + BinanceClient *binance.Client + Symbols []string + InvestUsdt map[string]float64 = make(map[string]float64) + SymbolInfos map[string]*binance.Symbol = make(map[string]*binance.Symbol) + + accountMu sync.Mutex // 保护 account 与数据库写入,避免并发轮询(若以后拆协程) + account map[string]float64 = make(map[string]float64) + + portfolioMu sync.Mutex // 保护 portfolio 与数据库写入,避免并发轮询(若以后拆协程) + portfolio = models.NewSpotPortfolioSnapshot() +) + // Boot 在独立协程中运行 Binance 现货轮询策略(反弹/布林开仓、分档加仓、跟踪止盈全平);内部为死循环不返回。 func Boot() { - runBinanceSpotStrategy() + // 检查参数 + if err := CheckArgs(); err != nil { + log.Fatal("logic: 参数检查失败: " + err.Error()) + } + + // 读取交易对参数 + if err := InitSymbolInfos(); err != nil { + log.Fatal("logic: 读取交易对参数失败: " + err.Error()) + } + + // 启动现货策略 + runSpotStrategy() +} + +func CheckArgs() error { + ctx := context.Background() + + if len(config.Spec.SpotWatchList) == 0 { + return errors.New("SpotWatchList 未配置或无效,跳过现货策略") + } + + key := config.Spec.BinanceApiKey + secret := config.Spec.BinanceApiSecret + if key == "" || secret == "" { + return errors.New("未配置 BinanceApiKey 或 BinanceApiSecret") + } + client := binance.NewClient(key, secret) + if err := client.NewPingService().Do(ctx); err != nil { + return errors.New("Binance Ping 失败: " + err.Error()) + } + acct, err := client.NewGetAccountService().Do(ctx) + if err != nil { + return errors.New("Binance 账户校验失败: " + err.Error()) + } + if !acct.CanTrade { + return errors.New("Binance 账户未开启现货交易权限") + } + if len(spotWatchesFromConfig()) == 0 { + return errors.New("SpotWatchList 未配置或无效,跳过现货策略") + } + log.Printf("logic: Binance 已连接,CanTrade=%v", acct.CanTrade) + BinanceClient = client + return nil +} + +// InitSymbolInfos 从 exchangeInfo 拉取规则,填充 SymbolInfos 与 stepSizes(供下单数量取整)。 +func InitSymbolInfos() error { + ctx := context.Background() + Symbols = nil + for _, item := range config.Spec.SpotWatchList { + s := strings.ToUpper(strings.TrimSpace(item.Symbol)) + if s != "" { + Symbols = append(Symbols, s) + InvestUsdt[s] = item.OrderQtyUsdt + } + } + if len(Symbols) == 0 { + return errors.New("SpotWatchList 中无有效 Symbol") + } + info, err := BinanceClient.NewExchangeInfoService().Symbols(Symbols...).Do(ctx) + if err != nil { + return err + } + for i := range info.Symbols { + s := &info.Symbols[i] + SymbolInfos[s.Symbol] = s + if lot := s.LotSizeFilter(); lot != nil && lot.StepSize != "" { + stepSizes[s.Symbol] = lot.StepSize + } + var cfgUsdt float64 + for _, it := range config.Spec.SpotWatchList { + if strings.ToUpper(strings.TrimSpace(it.Symbol)) == s.Symbol { + cfgUsdt = it.OrderQtyUsdt + break + } + } + minQty, step, marketMin, minNotional := "-", "-", "-", "-" + 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 配置OrderQtyUsdt=%.2f USDT | LOT最小量=%s 步长=%s | 市价单最小基础量=%s | minNotional=%s", + s.Symbol, cfgUsdt, minQty, step, marketMin, minNotional) + } + return nil +} + +func getSymbolInfo(symbol string) *binance.Symbol { + return SymbolInfos[symbol] +} + +// runBinanceSpotStrategy 入口:校验配置 → 连通性 → 死循环定时执行 spotTick。 +// 若 Key/Secret 为空或 API 失败会直接 return,不再占用协程(由 Boot 调用方决定是否在 goroutine 里跑)。 +func runSpotStrategy() { + if err := RefreshAccount(); err != nil { + log.Printf("logic: 刷新账户失败: %v", err) + } + + if err := loadPortfolio(); err != nil { + log.Printf("logic: 从数据库加载现货持仓失败(将使用空档): %v", err) + } + + portfolio := GetPortfolio() + for _, item := range config.Spec.SpotWatchList { + base := spotBaseFromSymbol(strings.ToUpper(strings.TrimSpace(item.Symbol))) + st := portfolio[base] + if st == nil || (st.Quantity <= 0 && st.AvgCostUSDT <= 0) { + log.Printf("logic: %s 无有效持仓记录,尝试建仓", item.Symbol) + CreateNewSpotPosition(item.Symbol) + } + } + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + for { + + ctx := context.Background() + if err := Run(ctx); err != nil { + log.Printf("logic: 现货策略轮询错误: %v", err) + } + <-ticker.C + } } diff --git a/internal/logic/open.go b/internal/logic/open.go new file mode 100644 index 0000000..5d75554 --- /dev/null +++ b/internal/logic/open.go @@ -0,0 +1,89 @@ +package logic + +import ( + "context" + "errors" + "log" + "strconv" + "time" + + "git.apinb.com/quant/coin/internal/impl" + "git.apinb.com/quant/coin/internal/models" + binance "github.com/adshao/go-binance/v2" +) + +// OpenPosition 按交易对用配置的 OrderQtyUsdt 市价买入首笔;更新内存、写入数据库并 loadPortfolio 刷新持仓缓存。 +// 若链上可用基础币名义已达「有仓」阈值则只同步数量与成本写库,不再重复下单。 +func CreateNewSpotPosition(symbol string) error { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + + if err := RefreshAccount(); err != nil { + return err + } else { + account := GetAccount("USDT") + if account <= 10 { + return errors.New("USDT 余额不足") + } + } + + qty, err := CalculateQty(symbol) + if err != nil { + return err + } + + order, err := BinanceClient.NewCreateOrderService(). + Symbol(symbol). + Side(binance.SideTypeBuy). + Type(binance.OrderTypeMarket). + Quantity(qty). + NewOrderRespType(binance.NewOrderRespTypeRESULT). + Do(ctx) + if err != nil { + return err + } + + position := &models.SpotPosition{ + BaseAsset: spotBaseFromSymbol(symbol), + Symbol: symbol, + Quantity: parseFloat(order.ExecutedQuantity), + AvgCostUSDT: parseFloat(order.Price), + } + + if err := impl.DBService.Create(position).Error; err != nil { + return err + } + + if err := loadPortfolio(); err != nil { + log.Printf("logic: OpenPosition 刷新持仓缓存失败: %v", err) + } + + return nil +} + +func CalculateQty(symbol string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + prices, err := BinanceClient.NewListPricesService().Symbol(symbol).Do(ctx) + if err != nil || len(prices) == 0 { + return "", err + } + price, err := strconv.ParseFloat(prices[0].Price, 64) + if err != nil || price <= 0 { + return "", errors.New("现价无效") + } + cfg := getSymbolInfo(symbol) + if cfg == nil { + return "", errors.New("交易对不存在") + } + want := InvestUsdt[cfg.Symbol] / price + step := cfg.LotSizeFilter().StepSize + qtyStr, ok, err := formatQtyToLotStep(want, step) + if err != nil { + return "", err + } + if !ok { + return "", errors.New("数量不足") + } + return qtyStr, nil +} diff --git a/internal/logic/run.go b/internal/logic/run.go new file mode 100644 index 0000000..e9d7e16 --- /dev/null +++ b/internal/logic/run.go @@ -0,0 +1,7 @@ +package logic + +import "context" + +func Run(ctx context.Context) error { + return spotTick(ctx) +} diff --git a/internal/logic/spot_binance.go b/internal/logic/spot_binance.go index 4c0e830..0b95af8 100644 --- a/internal/logic/spot_binance.go +++ b/internal/logic/spot_binance.go @@ -7,7 +7,6 @@ import ( "log" "strconv" "strings" - "sync" "time" "git.apinb.com/quant/coin/internal/config" @@ -18,7 +17,7 @@ import ( // 以下为可调参数,改数值即可调整策略行为(改后重新编译运行)。 const ( // pollInterval 两次完整轮询之间的间隔(拉余额、价格、判单、写库) - pollInterval = 60 * time.Second + pollInterval = 10 * time.Second // maxDipAdds 相对当前均价最多加仓次数(不含首仓) maxDipAdds = 4 @@ -27,8 +26,10 @@ const ( // trailPullbackPct 从跟踪期内最高价回撤本比例则触发市价全仓卖出 trailPullbackPct = 0.005 - // minHoldUSDT 持仓市值默认「无仓」判断的全局名义下限(USDT);单笔 OrderQty×价 接近该值时会用 spotHoldingThresholdUSDT 放宽,避免 LOT 取整后略低于本值被误判空仓。 + // minHoldUSDT 持仓市值默认「无仓」判断的全局名义下限(USDT);单笔 OrderQtyUsdt 接近该值时会用 spotHoldingThresholdUSDT 放宽,避免 LOT 取整后略低于本值被误判空仓。 minHoldUSDT = 10.0 + // minOrderQtyUsdt SpotWatchList 单笔开仓/加仓配置的 USDT 名义下限(与常见 minNotional 对齐)。 + minOrderQtyUsdt = 10.0 // minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional) minSellNotional = 10.0 @@ -42,27 +43,27 @@ const ( spotImmediateInitialOpen = true ) -// spotSymbol 单标的运行时视图:基础资产、交易对、每笔买入的基础币数量(来自配置 SpotWatchList)。 +// spotSymbol 单标的运行时视图:基础资产、交易对、每笔买入的 USDT 名义(来自配置 SpotWatchList)。 type spotSymbol struct { - Base string - Symbol string // 如 BTCUSDT - OrderQty float64 // 开仓与加仓共用(枚) + Base string + Symbol string // 如 BTCUSDT + OrderQtyUsdt float64 // 开仓与加仓共用(USDT 名义) } -// spotWatchesFromConfig 从 Spec.SpotWatchList 解析有效项;Symbol 空或 OrderQty≤0 的条目会被跳过。 +// spotWatchesFromConfig 从 Spec.SpotWatchList 解析有效项;Symbol 空或 OrderQtyUsdt < minOrderQtyUsdt 的条目会被跳过。 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 { + if sym == "" || it.OrderQtyUsdt < minOrderQtyUsdt { continue } us := strings.ToUpper(sym) out = append(out, spotSymbol{ - Base: spotBaseFromSymbol(us), - Symbol: us, - OrderQty: it.OrderQty, + Base: spotBaseFromSymbol(us), + Symbol: us, + OrderQtyUsdt: it.OrderQtyUsdt, }) } return out @@ -80,8 +81,6 @@ func spotBaseFromSymbol(symbolUpper string) string { var dipAddDrawdowns = [...]float64{0.05, 0.15, 0.30, 0.50} var ( - portfolioMu sync.Mutex // 保护 portfolio 与数据库写入,避免并发轮询(若以后拆协程) - portfolio = models.NewSpotPortfolioSnapshot() // stepSizes 各交易对 LOT_SIZE 的 stepSize,用于卖出数量按交易所步长向下取整 stepSizes = map[string]string{} // spotTickDirty 本轮是否发生过市价成交(首仓/加仓/全平)并改动了需持久化的字段;无成交则不写库。 @@ -93,74 +92,26 @@ func markSpotPortfolioDirty() { spotTickDirty = true } -// runBinanceSpotStrategy 入口:校验配置 → 连通性 → 死循环定时执行 spotTick。 -// 若 Key/Secret 为空或 API 失败会直接 return,不再占用协程(由 Boot 调用方决定是否在 goroutine 里跑)。 -func runBinanceSpotStrategy() { - ctx := context.Background() - key := config.Spec.BinanceApiKey - secret := config.Spec.BinanceApiSecret - if key == "" || secret == "" { - log.Printf("logic: 未配置 BinanceApiKey 或 BinanceApiSecret,跳过现货策略") - return - } - client := binance.NewClient(key, secret) - if err := client.NewPingService().Do(ctx); err != nil { - log.Printf("logic: Binance Ping 失败: %v", err) - return - } - acct, err := client.NewGetAccountService().Do(ctx) - if err != nil { - log.Printf("logic: Binance 账户校验失败: %v", err) - return - } - if !acct.CanTrade { - 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 { - 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) - defer ticker.Stop() - for { - if err := spotTick(ctx, client); err != nil { - log.Printf("logic: 现货策略轮询错误: %v", err) - } - <-ticker.C - } -} - // spotTick 单次轮询:拉账户 + 批量现价 → 对每个配置标的执行 processOne → 持久化到库。 -func spotTick(ctx context.Context, client *binance.Client) error { +func spotTick(ctx context.Context) 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) + + acct, err := BinanceClient.NewGetAccountService().Do(ctx) if err != nil { return err } + symbols := make([]string, len(watch)) for i, w := range watch { symbols[i] = w.Symbol } - prices, err := client.NewListPricesService().Symbols(symbols).Do(ctx) + prices, err := BinanceClient.NewListPricesService().Symbols(symbols).Do(ctx) if err != nil { return err } @@ -191,7 +142,7 @@ func spotTick(ctx context.Context, client *binance.Client) error { if !ok { continue } - if err := processOne(ctx, client, acct.Balances, w, px); err != nil { + if err := processOne(ctx, BinanceClient, acct.Balances, w, px); err != nil { log.Printf("logic: %s 处理失败: %v", w.Symbol, err) } } @@ -215,11 +166,11 @@ func getOrCreateState(base, symbol string) *models.SpotPosition { } // spotHoldingThresholdUSDT 判断是否视为「有仓」的名义市值下限(USDT)。 -// 若仅用固定 minHoldUSDT,而配置 OrderQty×现价 与 10U 接近,LOT 向下取整后实际成交量略小(如 0.0001→0.0000999), -// 名义可能略低于 10U,会被误判为空仓并重复首仓;对在全局门槛附近的单笔配置名义降低阈值并留松弛。 +// 若仅用固定 minHoldUSDT,而配置 OrderQtyUsdt 与 10U 接近,LOT 向下取整后实际成交名义略小于配置, +// 会被误判为空仓并重复首仓;对在全局门槛附近的单笔配置名义降低阈值并留松弛。 func spotHoldingThresholdUSDT(w spotSymbol, price float64) float64 { t := minHoldUSDT - nominal := w.OrderQty * price + nominal := w.OrderQtyUsdt if nominal >= minHoldUSDT*0.85 && nominal <= minHoldUSDT*1.25 { relaxed := nominal * 0.965 if relaxed < t { diff --git a/internal/logic/spot_binance_account.go b/internal/logic/spot_binance_account.go index 07a10a9..f138f97 100644 --- a/internal/logic/spot_binance_account.go +++ b/internal/logic/spot_binance_account.go @@ -2,74 +2,13 @@ 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" ) -// 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)) - for _, w := range watch { - syms = append(syms, w.Symbol) - } - info, err := client.NewExchangeInfoService().Symbols(syms...).Do(ctx) - if err != nil { - return err - } - 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 != "" { - 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 - } - 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 -} - // loadPortfolio 从 GORM 读入全表 spot_positions,填充内存 map(键为 BaseAsset)。 func loadPortfolio() error { portfolioMu.Lock() @@ -91,119 +30,37 @@ func loadPortfolio() error { 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 +func GetPortfolio() map[string]*models.SpotPosition { + portfolioMu.Lock() + defer portfolioMu.Unlock() + return portfolio.Positions } -// 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 -} - -// 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) +func RefreshAccount() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*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) + acct, err := BinanceClient.NewGetAccountService().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) + accountMu.Lock() + defer accountMu.Unlock() + for _, b := range acct.Balances { + free, err := strconv.ParseFloat(b.Free, 64) if err != nil { continue } - priceBySymbol[p.Symbol] = v + account[b.Asset] = free } - - 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 } + +func GetAccount(asset string) float64 { + accountMu.Lock() + defer accountMu.Unlock() + free, ok := account[asset] + if !ok { + return 0 + } + return free +} diff --git a/internal/logic/spot_binance_open.go b/internal/logic/spot_binance_open.go index c51c04d..73a8eb4 100644 --- a/internal/logic/spot_binance_open.go +++ b/internal/logic/spot_binance_open.go @@ -10,19 +10,19 @@ import ( "github.com/adshao/go-binance/v2" ) -// trySpotInitialEntry 视为空仓时按配置 OrderQty(基础币)市价买入,数量按 LOT_SIZE 向下取整。 +// trySpotInitialEntry 视为空仓时按配置 OrderQtyUsdt(USDT 名义)市价买入,数量按现价换算后按 LOT_SIZE 向下取整。 func trySpotInitialEntry(ctx context.Context, client *binance.Client, balances []binance.Balance, w spotSymbol, st *models.SpotPosition, price float64) error { - want := w.OrderQty + wantUsdt := w.OrderQtyUsdt order, qtyStr, err := executeSpotMarketBuy(ctx, client, balances, w, price) if err != nil { return err } applyBuyFill(st, order) - log.Printf("logic: %s 反弹≥%.2f%% 后初始建仓数量 %s (配置 %.8f), 成交均价约 %.4f", w.Symbol, buyReboundPct*100, qtyStr, want, st.AvgCostUSDT) + log.Printf("logic: %s 反弹≥%.2f%% 后初始建仓数量 %s (配置 %.2f USDT), 成交均价约 %.4f", w.Symbol, buyReboundPct*100, qtyStr, wantUsdt, st.AvgCostUSDT) return nil } -// trySpotDipAdd 相对当前均价达到第 (DipAddsDone+1) 档跌幅(5%/15%/30%/50%)且未锁波段时,先等反弹 buyReboundPct 再按 OrderQty 买入;最多加仓 maxDipAdds 次。 +// trySpotDipAdd 相对当前均价达到第 (DipAddsDone+1) 档跌幅(5%/15%/30%/50%)且未锁波段时,先等反弹 buyReboundPct 再按 OrderQtyUsdt 买入;最多加仓 maxDipAdds 次。 func trySpotDipAdd(ctx context.Context, client *binance.Client, balances []binance.Balance, w spotSymbol, price float64, st *models.SpotPosition) error { if st.DipAddsDone >= maxDipAdds { return nil @@ -43,7 +43,7 @@ func trySpotDipAdd(ctx context.Context, client *binance.Client, balances []binan if !spotReboundReady(&st.DipReboundLow, price) { return nil } - want := w.OrderQty + wantUsdt := w.OrderQtyUsdt order, qtyStr, err := executeSpotMarketBuy(ctx, client, balances, w, price) if err != nil { return err @@ -53,13 +53,13 @@ func trySpotDipAdd(ctx context.Context, client *binance.Client, balances []binan st.DipLegLocked = true st.DipReboundLow = 0 resetSpotTrail(st) - log.Printf("logic: %s 第 %d 次超跌(跌幅%.0f%%)反弹≥%.2f%% 后加仓数量 %s (配置 %.8f), 新成本约 %.4f", w.Symbol, st.DipAddsDone, draw*100, buyReboundPct*100, qtyStr, want, st.AvgCostUSDT) + log.Printf("logic: %s 第 %d 次超跌(跌幅%.0f%%)反弹≥%.2f%% 后加仓数量 %s (配置 %.2f USDT), 新成本约 %.4f", w.Symbol, st.DipAddsDone, draw*100, buyReboundPct*100, qtyStr, wantUsdt, st.AvgCostUSDT) return nil } // executeSpotMarketBuy 校验 USDT 后下市价买单(FULL),返回成交回报与已取整数量字符串。 func executeSpotMarketBuy(ctx context.Context, client *binance.Client, balances []binance.Balance, w spotSymbol, price float64) (*binance.CreateOrderResponse, string, error) { - qtyStr, _, err := spotBuyQtyString(w) + qtyStr, _, err := spotBuyQtyString(w, price) if err != nil { return nil, "", err } @@ -84,9 +84,12 @@ func executeSpotMarketBuy(ctx context.Context, client *binance.Client, balances return order, qtyStr, nil } -// spotBuyQtyString 将配置的 OrderQty 按交易对 LOT_SIZE 向下取整为下单字符串;want 为配置原值。 -func spotBuyQtyString(w spotSymbol) (qtyStr string, want float64, err error) { - want = w.OrderQty +// spotBuyQtyString 将 OrderQtyUsdt 按现价换为基础币数量,再按 LOT_SIZE 向下取整为下单字符串;want 为换算后的基础币数量(取整前)。 +func spotBuyQtyString(w spotSymbol, price float64) (qtyStr string, want float64, err error) { + if price <= 0 { + return "", 0, fmt.Errorf("%s: 现价无效,无法由 OrderQtyUsdt 换算数量", w.Symbol) + } + want = w.OrderQtyUsdt / price step := spotLotStep(w.Symbol) ok := false qtyStr, ok, err = formatQtyToLotStep(want, step) @@ -94,7 +97,7 @@ func spotBuyQtyString(w spotSymbol) (qtyStr string, want float64, err error) { return "", want, err } if !ok { - return "", want, fmt.Errorf("%s: OrderQty=%g 按 LOT_SIZE(%s) 取整后为 0,请调大数量或检查交易对", w.Symbol, want, step) + return "", want, fmt.Errorf("%s: OrderQtyUsdt=%g USDT 按现价与 LOT_SIZE(%s) 取整后为 0,请调大 OrderQtyUsdt(≥%.0f)或检查交易对", w.Symbol, w.OrderQtyUsdt, step, minOrderQtyUsdt) } return qtyStr, want, nil } diff --git a/internal/logic/ticker.go b/internal/logic/ticker.go new file mode 100644 index 0000000..b1ea593 --- /dev/null +++ b/internal/logic/ticker.go @@ -0,0 +1,27 @@ +package logic + +import ( + "context" + "strconv" + "time" +) + +// GetTick 单次轮询。 +func GetTick(ctx context.Context) (map[string]float64, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + prices, err := BinanceClient.NewListPricesService().Symbols(Symbols).Do(ctx) + if err != nil { + return nil, 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 + } + return priceBySymbol, nil +}