This commit is contained in:
2026-05-10 23:23:38 +08:00
parent 00dac88290
commit 1ee43872fe
12 changed files with 357 additions and 266 deletions

View File

@@ -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. 有仓

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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"`
}

View File

@@ -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
}
}

89
internal/logic/open.go Normal file
View File

@@ -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
}

7
internal/logic/run.go Normal file
View File

@@ -0,0 +1,7 @@
package logic
import "context"
func Run(ctx context.Context) error {
return spotTick(ctx)
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -10,19 +10,19 @@ import (
"github.com/adshao/go-binance/v2"
)
// trySpotInitialEntry 视为空仓时按配置 OrderQty(基础币)市价买入,数量按 LOT_SIZE 向下取整。
// trySpotInitialEntry 视为空仓时按配置 OrderQtyUsdtUSDT 名义)市价买入,数量按现价换算后按 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
}

27
internal/logic/ticker.go Normal file
View File

@@ -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
}