deving
This commit is contained in:
@@ -18,9 +18,7 @@ BinanceApiSecret: "YqTpRybnBWllS0fA1yk0T1MEx0RxRazc2bH2iZuPEI8QJKesUueq3saCDdDj7
|
||||
# 现货轮询标的:Symbol 为交易对,OrderQty 为每笔开仓/加仓买入的基础币数量(列表为空则不调现货策略)
|
||||
SpotWatchList:
|
||||
- Symbol: BTCUSDT
|
||||
OrderQty: 0.001
|
||||
- Symbol: ETHUSDT
|
||||
OrderQty: 0.01
|
||||
OrderQty: 0.00001
|
||||
|
||||
# 日志配置
|
||||
Log:
|
||||
|
||||
@@ -15,11 +15,10 @@ Cache: redis://null:Weidong2023~!@139.224.247.176:19379/1
|
||||
BinanceApiKey: "qZrLXzykvb7w7uKVJ45rXomp94BiJnk4a4tsaduAHKMYlljVcwWsZzOA3pYdGnqo"
|
||||
BinanceApiSecret: "YqTpRybnBWllS0fA1yk0T1MEx0RxRazc2bH2iZuPEI8QJKesUueq3saCDdDj7hpU"
|
||||
|
||||
# 现货轮询标的:Symbol 为交易对,OrderQty 为每笔开仓/加仓买入的基础币数量(列表为空则不调现货策略)
|
||||
SpotWatchList:
|
||||
- Symbol: BTCUSDT
|
||||
OrderQty: 0.001
|
||||
- Symbol: ETHUSDT
|
||||
OrderQty: 0.01
|
||||
OrderQty: 0.0001
|
||||
|
||||
# 日志配置
|
||||
Log:
|
||||
|
||||
@@ -15,11 +15,10 @@ Cache: redis://null:Weidong2023~!@139.224.247.176:19379/1
|
||||
BinanceApiKey: "qZrLXzykvb7w7uKVJ45rXomp94BiJnk4a4tsaduAHKMYlljVcwWsZzOA3pYdGnqo"
|
||||
BinanceApiSecret: "YqTpRybnBWllS0fA1yk0T1MEx0RxRazc2bH2iZuPEI8QJKesUueq3saCDdDj7hpU"
|
||||
|
||||
# 现货轮询标的:Symbol 为交易对,OrderQty 为每笔开仓/加仓买入的基础币数量(列表为空则不调现货策略)
|
||||
SpotWatchList:
|
||||
- Symbol: BTCUSDT
|
||||
OrderQty: 0.001
|
||||
- Symbol: ETHUSDT
|
||||
OrderQty: 0.01
|
||||
OrderQty: 0.00001
|
||||
|
||||
# 日志配置
|
||||
Log:
|
||||
|
||||
@@ -3,6 +3,7 @@ package logic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -26,7 +27,7 @@ const (
|
||||
// trailPullbackPct 从跟踪期内最高价回撤本比例则触发市价全仓卖出
|
||||
trailPullbackPct = 0.005
|
||||
|
||||
// minHoldUSDT 持仓市值低于此值(USDT)视为「无仓」,会按配置 OrderQty 市价买基础币建首笔
|
||||
// minHoldUSDT 持仓市值默认「无仓」判断的全局名义下限(USDT);单笔 OrderQty×价 接近该值时会用 spotHoldingThresholdUSDT 放宽,避免 LOT 取整后略低于本值被误判空仓。
|
||||
minHoldUSDT = 10.0
|
||||
// minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional)
|
||||
minSellNotional = 10.0
|
||||
@@ -35,6 +36,10 @@ const (
|
||||
dipRecoverPct = 0.03
|
||||
// buyReboundPct 开仓/加仓前须相对阶段低点反弹超过本比例才市价买入(持续创新低则不买)
|
||||
buyReboundPct = 0.0039
|
||||
|
||||
// spotImmediateInitialOpen 为 true 时,仅当策略侧从未有过持仓(成本与数量均为 0)时跳过「先锚定再等反弹」,
|
||||
// 启动后首轮即可市价开首仓;曾经建仓后又全平的标的仍须等反弹后再进,避免刚卖立刻买回。
|
||||
spotImmediateInitialOpen = true
|
||||
)
|
||||
|
||||
// spotSymbol 单标的运行时视图:基础资产、交易对、每笔买入的基础币数量(来自配置 SpotWatchList)。
|
||||
@@ -79,8 +84,15 @@ var (
|
||||
portfolio = models.NewSpotPortfolioSnapshot()
|
||||
// stepSizes 各交易对 LOT_SIZE 的 stepSize,用于卖出数量按交易所步长向下取整
|
||||
stepSizes = map[string]string{}
|
||||
// spotTickDirty 本轮是否发生过市价成交(首仓/加仓/全平)并改动了需持久化的字段;无成交则不写库。
|
||||
// 无成交时的 trail/反弹锚点等仅驻内存,进程异常退出可能丢失该段进度(下轮仍可从账户与价格继续推演)。
|
||||
spotTickDirty bool
|
||||
)
|
||||
|
||||
func markSpotPortfolioDirty() {
|
||||
spotTickDirty = true
|
||||
}
|
||||
|
||||
// runBinanceSpotStrategy 入口:校验配置 → 连通性 → 死循环定时执行 spotTick。
|
||||
// 若 Key/Secret 为空或 API 失败会直接 return,不再占用协程(由 Boot 调用方决定是否在 goroutine 里跑)。
|
||||
func runBinanceSpotStrategy() {
|
||||
@@ -114,8 +126,12 @@ func runBinanceSpotStrategy() {
|
||||
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)
|
||||
@@ -156,10 +172,20 @@ func spotTick(ctx context.Context, client *binance.Client) error {
|
||||
}
|
||||
priceBySymbol[p.Symbol] = v
|
||||
}
|
||||
parts := make([]string, 0, len(watch))
|
||||
for _, w := range watch {
|
||||
if px, ok := priceBySymbol[w.Symbol]; ok {
|
||||
parts = append(parts, fmt.Sprintf("%s=%.8f", w.Symbol, px))
|
||||
} else {
|
||||
parts = append(parts, w.Symbol+"=?")
|
||||
}
|
||||
}
|
||||
log.Printf("logic: 现货轮询现价 %s", strings.Join(parts, ", "))
|
||||
|
||||
portfolioMu.Lock()
|
||||
defer portfolioMu.Unlock()
|
||||
|
||||
spotTickDirty = false
|
||||
for _, w := range watch {
|
||||
px, ok := priceBySymbol[w.Symbol]
|
||||
if !ok {
|
||||
@@ -169,6 +195,9 @@ func spotTick(ctx context.Context, client *binance.Client) error {
|
||||
log.Printf("logic: %s 处理失败: %v", w.Symbol, err)
|
||||
}
|
||||
}
|
||||
if !spotTickDirty {
|
||||
return nil
|
||||
}
|
||||
return savePortfolioLocked()
|
||||
}
|
||||
|
||||
@@ -185,6 +214,24 @@ func getOrCreateState(base, symbol string) *models.SpotPosition {
|
||||
return st
|
||||
}
|
||||
|
||||
// spotHoldingThresholdUSDT 判断是否视为「有仓」的名义市值下限(USDT)。
|
||||
// 若仅用固定 minHoldUSDT,而配置 OrderQty×现价 与 10U 接近,LOT 向下取整后实际成交量略小(如 0.0001→0.0000999),
|
||||
// 名义可能略低于 10U,会被误判为空仓并重复首仓;对在全局门槛附近的单笔配置名义降低阈值并留松弛。
|
||||
func spotHoldingThresholdUSDT(w spotSymbol, price float64) float64 {
|
||||
t := minHoldUSDT
|
||||
nominal := w.OrderQty * price
|
||||
if nominal >= minHoldUSDT*0.85 && nominal <= minHoldUSDT*1.25 {
|
||||
relaxed := nominal * 0.965
|
||||
if relaxed < t {
|
||||
t = relaxed
|
||||
}
|
||||
}
|
||||
if t < 0.3 {
|
||||
t = 0.3
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// processOne 单标的一轮决策:无仓建仓 → 有仓则判超跌买 / 冲高卖,并维护锁与成本。
|
||||
func processOne(ctx context.Context, client *binance.Client, balances []binance.Balance, w spotSymbol, price float64) error {
|
||||
free, err := balanceFree(balances, w.Base)
|
||||
@@ -193,17 +240,21 @@ func processOne(ctx context.Context, client *binance.Client, balances []binance.
|
||||
}
|
||||
st := getOrCreateState(w.Base, w.Symbol)
|
||||
positionUSDT := free * price
|
||||
holdTh := spotHoldingThresholdUSDT(w, price)
|
||||
|
||||
if positionUSDT >= minHoldUSDT {
|
||||
if positionUSDT >= holdTh {
|
||||
st.OpenReboundLow = 0
|
||||
}
|
||||
|
||||
if positionUSDT < minHoldUSDT {
|
||||
if positionUSDT < holdTh {
|
||||
st.DipAddsDone = 0
|
||||
st.DipLegLocked = false
|
||||
st.RallyLegLocked = false
|
||||
resetSpotTrail(st)
|
||||
if !spotReboundReady(&st.OpenReboundLow, price) {
|
||||
neverEntered := st.AvgCostUSDT <= 0 && st.Quantity <= 0
|
||||
ready := (spotImmediateInitialOpen && neverEntered) || spotReboundReady(&st.OpenReboundLow, price)
|
||||
if !ready {
|
||||
log.Printf("logic: %s 空仓 现价=%.8f 阶段低=%.8f 待反弹≥%.3f%% 才首仓 (持仓≈%.2f USDT)", w.Symbol, price, st.OpenReboundLow, buyReboundPct*100, positionUSDT)
|
||||
return nil
|
||||
}
|
||||
err = trySpotInitialEntry(ctx, client, balances, w, st, price)
|
||||
|
||||
@@ -2,14 +2,24 @@ 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"
|
||||
)
|
||||
|
||||
// refreshStepSizes 从 exchangeInfo 拉取各 symbol 的 LOT_SIZE.stepSize,供卖出数量格式化。
|
||||
// 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))
|
||||
@@ -20,12 +30,42 @@ func refreshStepSizes(ctx context.Context, client *binance.Client) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range info.Symbols {
|
||||
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 == "" {
|
||||
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
|
||||
}
|
||||
stepSizes[s.Symbol] = lot.StepSize
|
||||
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
|
||||
}
|
||||
@@ -77,3 +117,93 @@ func balanceFree(balances []binance.Balance, asset string) (float64, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -39,9 +39,16 @@ func trySpotRallySell(ctx context.Context, client *binance.Client, w spotSymbol,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || parseFloat(qtyStr)*price < minSellNotional {
|
||||
if !ok {
|
||||
log.Printf("logic: %s 跟踪止盈回撤触发但可卖数量按 LOT 步长取整为 0(free=%.12f),解除跟踪;dust 请手工或下轮余额变化后再管", w.Symbol, free)
|
||||
resetSpotTrail(st)
|
||||
markSpotPortfolioDirty()
|
||||
return nil
|
||||
}
|
||||
nominal := parseFloat(qtyStr) * price
|
||||
if nominal < minSellNotional {
|
||||
log.Printf("logic: %s 跟踪止盈:卖出名义约 %.4f USDT 低于参考门槛 %.2f,仍尝试市价可卖尽卖", w.Symbol, nominal, minSellNotional)
|
||||
}
|
||||
order, err := client.NewCreateOrderService().
|
||||
Symbol(w.Symbol).
|
||||
Side(binance.SideTypeSell).
|
||||
@@ -59,6 +66,7 @@ func trySpotRallySell(ctx context.Context, client *binance.Client, w spotSymbol,
|
||||
}
|
||||
peak := st.TrailPeakUSDT
|
||||
resetSpotTrail(st)
|
||||
markSpotPortfolioDirty()
|
||||
log.Printf("logic: %s 从跟踪高点 %.6f 回撤≥%.1f%% 全平, 卖出数量 %s", w.Symbol, peak, trailPullbackPct*100, qtyStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -118,4 +118,5 @@ func applyBuyFill(st *models.SpotPosition, order *binance.CreateOrderResponse) {
|
||||
st.AvgCostUSDT = (oldQty*oldCost + quote) / newQty
|
||||
}
|
||||
st.Quantity = newQty
|
||||
markSpotPortfolioDirty()
|
||||
}
|
||||
|
||||
@@ -4,3 +4,5 @@ GOARCH=amd64 GOOS=linux go build -o ../builds/coin ./cmd/main/main.go
|
||||
|
||||
BSM_RuntimeMode=prod BSM_Prefix=/data/app/ nohup ./coin > /data/app/logs/coin.log 2>&1 &
|
||||
cat /data/app/logs/coin.log
|
||||
|
||||
BSM_RuntimeMode=prod BSM_Prefix=/data/app/ ./coin
|
||||
Reference in New Issue
Block a user