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() 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 } // 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 }