deving
This commit is contained in:
@@ -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. 有仓
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
89
internal/logic/open.go
Normal 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
7
internal/logic/run.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package logic
|
||||
|
||||
import "context"
|
||||
|
||||
func Run(ctx context.Context) error {
|
||||
return spotTick(ctx)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
27
internal/logic/ticker.go
Normal file
27
internal/logic/ticker.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user