380 lines
11 KiB
Go
380 lines
11 KiB
Go
|
|
// Package logic 提供 Binance 现货策略;本文件为定时轮询(非 WebSocket)的建仓、超跌加仓与冲高减仓。
|
|||
|
|
package logic
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"errors"
|
|||
|
|
"log"
|
|||
|
|
"strconv"
|
|||
|
|
"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"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 以下为可调参数,改数值即可调整策略行为(改后重新编译运行)。
|
|||
|
|
const (
|
|||
|
|
// pollInterval 两次完整轮询之间的间隔(拉余额、价格、判单、写库)
|
|||
|
|
pollInterval = 60 * time.Second
|
|||
|
|
|
|||
|
|
// buyQuoteUSDT 每次市价买入使用的报价资产数量(USDT)
|
|||
|
|
buyQuoteUSDT = 100.0
|
|||
|
|
// dipPct 相对成本价的下跌比例,跌破则触发加仓(若本波未锁)
|
|||
|
|
dipPct = 0.10
|
|||
|
|
// profitPct 相对成本价的上涨比例,涨破则触发减仓(若本波未锁)
|
|||
|
|
profitPct = 0.10
|
|||
|
|
// sellFraction 上涨触发时,卖出当前可用基础资产的比例
|
|||
|
|
sellFraction = 0.20
|
|||
|
|
|
|||
|
|
// minHoldUSDT 持仓市值低于此值(USDT)视为「无仓」,会先建首笔 100U
|
|||
|
|
minHoldUSDT = 10.0
|
|||
|
|
// minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional)
|
|||
|
|
minSellNotional = 10.0
|
|||
|
|
|
|||
|
|
// dipRecoverPct 超跌加仓后,现价需高于「成本×(1+本值)」才解除加仓锁,避免同一低位反复买
|
|||
|
|
dipRecoverPct = 0.03
|
|||
|
|
// rallyResetHighPct 冲高减仓后,现价需低于「成本×(1+本值)」才解除减仓锁,避免同一高位反复卖
|
|||
|
|
rallyResetHighPct = 0.06
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// spotSymbol 把「账户里的资产名」和「交易对」绑在一起,避免硬编码散落各处。
|
|||
|
|
type spotSymbol struct {
|
|||
|
|
Base string // 如 BTC
|
|||
|
|
Symbol string // 如 BTCUSDT
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// watchList 策略监控的标的列表;增删币种时改此处即可。
|
|||
|
|
var watchList = []spotSymbol{
|
|||
|
|
{"BTC", "BTCUSDT"},
|
|||
|
|
{"ETH", "ETHUSDT"},
|
|||
|
|
{"SOL", "SOLUSDT"},
|
|||
|
|
{"XRP", "XRPUSDT"},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var (
|
|||
|
|
portfolioMu sync.Mutex // 保护 portfolio 与数据库写入,避免并发轮询(若以后拆协程)
|
|||
|
|
portfolio = models.NewSpotPortfolioSnapshot()
|
|||
|
|
// stepSizes 各交易对 LOT_SIZE 的 stepSize,用于卖出数量按交易所步长向下取整
|
|||
|
|
stepSizes = map[string]string{}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 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
|
|||
|
|
}
|
|||
|
|
log.Printf("logic: Binance 已连接,CanTrade=%v", acct.CanTrade)
|
|||
|
|
|
|||
|
|
if err := loadPortfolio(); err != nil {
|
|||
|
|
log.Printf("logic: 从数据库加载现货持仓失败(将使用空档): %v", err)
|
|||
|
|
}
|
|||
|
|
if err := refreshStepSizes(ctx, client); err != nil {
|
|||
|
|
log.Printf("logic: 加载交易对精度失败: %v", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ticker := time.NewTicker(pollInterval)
|
|||
|
|
defer ticker.Stop()
|
|||
|
|
for {
|
|||
|
|
if err := spotTick(ctx, client); err != nil {
|
|||
|
|
log.Printf("logic: 现货策略轮询错误: %v", err)
|
|||
|
|
}
|
|||
|
|
<-ticker.C
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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 → 持久化到库。
|
|||
|
|
func spotTick(ctx context.Context, client *binance.Client) error {
|
|||
|
|
acct, err := client.NewGetAccountService().Do(ctx)
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
symbols := make([]string, len(watchList))
|
|||
|
|
for i, w := range watchList {
|
|||
|
|
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 watchList {
|
|||
|
|
px, ok := priceBySymbol[w.Symbol]
|
|||
|
|
if !ok {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if err := processOne(ctx, client, acct.Balances, w, px); err != nil {
|
|||
|
|
log.Printf("logic: %s 处理失败: %v", w.Symbol, err)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return savePortfolioLocked()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// getOrCreateState 按基础资产取状态;不存在则新建并挂到 portfolio 上(首轮 Save 时插入表)。
|
|||
|
|
func getOrCreateState(base, symbol string) *models.SpotPosition {
|
|||
|
|
st, ok := portfolio.Positions[base]
|
|||
|
|
if !ok {
|
|||
|
|
st = &models.SpotPosition{
|
|||
|
|
BaseAsset: base,
|
|||
|
|
Symbol: symbol,
|
|||
|
|
}
|
|||
|
|
portfolio.Positions[base] = st
|
|||
|
|
}
|
|||
|
|
return st
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// processOne 单标的一轮决策:无仓建仓 → 有仓则判超跌买 / 冲高卖,并维护锁与成本。
|
|||
|
|
func processOne(ctx context.Context, client *binance.Client, balances []binance.Balance, w spotSymbol, price float64) error {
|
|||
|
|
free, err := balanceFree(balances, w.Base)
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
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 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 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
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// formatQtyForSell 将「计划卖出数量」按 stepSize 向下取整,满足 Binance LOT_SIZE;过小则返回 ok=false。
|
|||
|
|
func formatQtyForSell(qty float64, stepSize string) (string, bool, error) {
|
|||
|
|
if qty <= 0 {
|
|||
|
|
return "", false, nil
|
|||
|
|
}
|
|||
|
|
step, err := decimal.NewFromString(stepSize)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", false, err
|
|||
|
|
}
|
|||
|
|
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
|
|||
|
|
}
|