feat
This commit is contained in:
83
README.md
83
README.md
@@ -1,3 +1,84 @@
|
|||||||
# coin
|
# coin
|
||||||
|
|
||||||
coin quant trade
|
基于 **Binance 现货 USDT** 的定时轮询量化服务:按配置多标的运行,使用 Postgres 持久化持仓状态,策略在 `internal/logic` 中实现。
|
||||||
|
|
||||||
|
## 功能概览
|
||||||
|
|
||||||
|
- **入口**:`cmd/main/main.go` 加载配置、初始化 DB/Redis、`go logic.Boot()` 启动策略协程,主线程跑 Gin HTTP(端口见配置 `Port`)。
|
||||||
|
- **策略**:`logic.runBinanceSpotStrategy` 每 `pollInterval`(默认 60s)拉账户余额与批量现价,对 `SpotWatchList` 中每个交易对执行决策并写回 `spot_positions`。
|
||||||
|
- **无 WebSocket**:全部为 REST 轮询,实现简单、对网络抖动相对不敏感,但成交与价格存在轮询间隔内的滞后。
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
配置文件见 `etc/coin_<env>.yaml`(由 bsm-sdk `conf.New` 按服务名加载),与策略相关的字段包括:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| `BinanceApiKey` / `BinanceApiSecret` | 现货 API;需在 Binance 开启**现货交易**权限 |
|
||||||
|
| `SpotWatchList` | 标的列表;为空则跳过整个现货策略 |
|
||||||
|
| `SpotWatchList[].Symbol` | 交易对,如 `BTCUSDT` |
|
||||||
|
| `SpotWatchList[].OrderQty` | 每次**开仓**与**加仓**买入的**基础币数量**(按 LOT_SIZE 向下取整) |
|
||||||
|
| `Databases` / `Cache` | 由 `impl.NewImpl` 初始化,供迁移与其它模块使用 |
|
||||||
|
|
||||||
|
## 策略逻辑(按执行顺序)
|
||||||
|
|
||||||
|
对配置中每个 `Symbol`,在单次轮询内大致顺序如下(详见 `spot_binance.go` 的 `processOne`)。
|
||||||
|
|
||||||
|
### 1. 空仓(持仓市值 < `minHoldUSDT`,默认 10 USDT)
|
||||||
|
|
||||||
|
- 重置:`DipAddsDone`、`DipLegLocked`、跟踪止盈状态、`OpenReboundLow` 等。
|
||||||
|
- **反弹过滤**:用 `OpenReboundLow` 记录阶段低价;价格**持续创新低**时不买;现价相对该低点**反弹 ≥ `buyReboundPct`(0.39%)** 后才允许首买。
|
||||||
|
- 按 `OrderQty` 取整后下**市价买单**(`Quantity`),更新加权成本与数量。
|
||||||
|
|
||||||
|
### 2. 有仓
|
||||||
|
|
||||||
|
- 若本地成本未写过(如手工划转),用当前价初始化 `AvgCostUSDT`。
|
||||||
|
- **分档超跌加仓**(相对**当前加权均价**,每档最多一次,且需反弹与波段锁):
|
||||||
|
- 第 1~4 次加仓对应跌幅阈值:**5% / 15% / 30% / 50%**(`dipAddDrawdowns`)。
|
||||||
|
- 现价 ≤ `均价 × (1 − 当前档跌幅)` 时进入该档观察;同样用 `DipReboundLow` 要求**先反弹 ≥ `buyReboundPct`** 再下单。
|
||||||
|
- 每次加仓后 `DipLegLocked`,需现价 ≥ `均价 × (1 + dipRecoverPct)`(默认 3%)才解锁,避免同一低位反复买。
|
||||||
|
- `DipAddsDone == 4` 后不再加仓。
|
||||||
|
- **跟踪止盈全平**:
|
||||||
|
- 浮盈 ≥ **`profitArmPct`(5%)** 后进入跟踪,记录阶段最高价;**不在刚达 5% 时直接卖**。
|
||||||
|
- 现价从跟踪最高价回撤 ≥ **`trailPullbackPct`(0.5%)** 时,**市价卖出全部可用基础币**(按 LOT 取整,且名义需 ≥ `minSellNotional`)。
|
||||||
|
|
||||||
|
### 3. 常量一览(改代码后需重新编译)
|
||||||
|
|
||||||
|
| 常量 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `pollInterval` | 60s | 轮询周期 |
|
||||||
|
| `minHoldUSDT` | 10 | 低于视为空仓 |
|
||||||
|
| `buyReboundPct` | 0.39% | 开/加仓前相对阶段低点的最小反弹 |
|
||||||
|
| `maxDipAdds` | 4 | 最大加仓次数 |
|
||||||
|
| `dipAddDrawdowns` | 5%,15%,30%,50% | 各次加仓相对均价的跌幅门槛 |
|
||||||
|
| `dipRecoverPct` | 3% | 加仓后解锁下一档所需相对均价的涨幅 |
|
||||||
|
| `profitArmPct` | 5% | 开始跟踪高点的浮盈线 |
|
||||||
|
| `trailPullbackPct` | 0.5% | 从跟踪高点回撤触发全平 |
|
||||||
|
| `minSellNotional` | 10 USDT | 卖单名义过小则跳过 |
|
||||||
|
|
||||||
|
## 数据表 `spot_positions`
|
||||||
|
|
||||||
|
由 GORM 迁移(`models.SpotPosition`),按 `BaseAsset` 唯一一行,主要字段包括:均价、数量、加仓次数与锁、`TrailArmed` / `TrailPeakUSDT`、开/加仓反弹低点、`DipReboundLow` 等。策略重启后会从库加载到内存再继续。
|
||||||
|
|
||||||
|
## 代码结构(策略相关)
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|------|------|
|
||||||
|
| `spot_binance.go` | 轮询入口、`processOne`、配置解析、反弹判断、分档跌幅表 |
|
||||||
|
| `spot_binance_open.go` | 首买、分档加仓、市价买统一执行、成交更新成本 |
|
||||||
|
| `spot_binance_close.go` | 跟踪止盈全平、`spotLotStep`、`formatQtyToLotStep` |
|
||||||
|
| `spot_binance_account.go` | 精度、持仓加载/保存、余额解析 |
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 在正确 GOPATH / module 环境下
|
||||||
|
go build -o coin.exe ./cmd/main
|
||||||
|
./coin.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
需保证配置中数据库、Binance 密钥与 `SpotWatchList` 正确;**实盘有资金风险**,请在测试网或小资金下验证后再使用。
|
||||||
|
|
||||||
|
## 免责声明
|
||||||
|
|
||||||
|
本仓库代码仅供学习与研究,不构成投资建议。使用本软件进行交易的一切损失由使用者自行承担。
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ Cache: redis://null:Weidong2023~!@139.224.247.176:19379/1
|
|||||||
BinanceApiKey: ""
|
BinanceApiKey: ""
|
||||||
BinanceApiSecret: ""
|
BinanceApiSecret: ""
|
||||||
|
|
||||||
|
# 现货轮询标的:Symbol 为交易对,OrderQty 为每笔开仓/加仓买入的基础币数量(列表为空则不调现货策略)
|
||||||
|
SpotWatchList:
|
||||||
|
- Symbol: BTCUSDT
|
||||||
|
OrderQty: 0.001
|
||||||
|
- Symbol: ETHUSDT
|
||||||
|
OrderQty: 0.01
|
||||||
|
|
||||||
# 日志配置
|
# 日志配置
|
||||||
Log:
|
Log:
|
||||||
Level: 0 # 0:debug, 1:info, 2:warn, 3:error, 4:fatal
|
Level: 0 # 0:debug, 1:info, 2:warn, 3:error, 4:fatal
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ Cache: redis://null:Weidong2023~!@139.224.247.176:19379/1
|
|||||||
BinanceApiKey: ""
|
BinanceApiKey: ""
|
||||||
BinanceApiSecret: ""
|
BinanceApiSecret: ""
|
||||||
|
|
||||||
|
SpotWatchList:
|
||||||
|
- Symbol: BTCUSDT
|
||||||
|
OrderQty: 0.001
|
||||||
|
- Symbol: ETHUSDT
|
||||||
|
OrderQty: 0.01
|
||||||
|
|
||||||
# 日志配置
|
# 日志配置
|
||||||
Log:
|
Log:
|
||||||
Level: 0 # 0:debug, 1:info, 2:warn, 3:error, 4:fatal
|
Level: 0 # 0:debug, 1:info, 2:warn, 3:error, 4:fatal
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ Cache: redis://null:Weidong2023~!@139.224.247.176:19379/1
|
|||||||
BinanceApiKey: ""
|
BinanceApiKey: ""
|
||||||
BinanceApiSecret: ""
|
BinanceApiSecret: ""
|
||||||
|
|
||||||
|
SpotWatchList:
|
||||||
|
- Symbol: BTCUSDT
|
||||||
|
OrderQty: 0.001
|
||||||
|
- Symbol: ETHUSDT
|
||||||
|
OrderQty: 0.01
|
||||||
|
|
||||||
# 日志配置
|
# 日志配置
|
||||||
Log:
|
Log:
|
||||||
Level: 0 # 0:debug, 1:info, 2:warn, 3:error, 4:fatal
|
Level: 0 # 0:debug, 1:info, 2:warn, 3:error, 4:fatal
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ var (
|
|||||||
Spec SrvConfig
|
Spec SrvConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SpotWatchItem 现货策略单标的:交易对标识 + 每笔市价买入的基础资产数量(开仓与超跌加仓相同)。
|
||||||
|
type SpotWatchItem struct {
|
||||||
|
Symbol string `yaml:"Symbol"` // 如 BTCUSDT
|
||||||
|
OrderQty float64 `yaml:"OrderQty"` // 每次开仓/加仓买入的基础币数量,须 > 0,会按交易所 LOT_SIZE 向下取整
|
||||||
|
}
|
||||||
|
|
||||||
type SrvConfig struct {
|
type SrvConfig struct {
|
||||||
conf.Base `yaml:",inline"`
|
conf.Base `yaml:",inline"`
|
||||||
ApiPort string `yaml:"ApiPort"`
|
ApiPort string `yaml:"ApiPort"`
|
||||||
@@ -18,6 +24,8 @@ type SrvConfig struct {
|
|||||||
Databases *conf.DBConf `yaml:"Databases"`
|
Databases *conf.DBConf `yaml:"Databases"`
|
||||||
BinanceApiKey string `yaml:"BinanceApiKey"`
|
BinanceApiKey string `yaml:"BinanceApiKey"`
|
||||||
BinanceApiSecret string `yaml:"BinanceApiSecret"`
|
BinanceApiSecret string `yaml:"BinanceApiSecret"`
|
||||||
|
// SpotWatchList 现货轮询标的;为空则跳过现货策略
|
||||||
|
SpotWatchList []SpotWatchItem `yaml:"SpotWatchList"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(srvKey string) {
|
func New(srvKey string) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package logic
|
package logic
|
||||||
|
|
||||||
// Boot 阻塞运行 Binance 现货策略(建仓、下跌加仓、上涨减仓);由 main 中 go 调用。
|
// Boot 在独立协程中运行 Binance 现货轮询策略(反弹/布林开仓、分档加仓、跟踪止盈全平);内部为死循环不返回。
|
||||||
func Boot() {
|
func Boot() {
|
||||||
runBinanceSpotStrategy()
|
runBinanceSpotStrategy()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
// Package logic 提供 Binance 现货策略;本文件为定时轮询(非 WebSocket)的建仓、超跌加仓与冲高减仓。
|
// Package logic 提供 Binance 现货策略;本组文件为定时轮询(非 WebSocket)的建仓、超跌加仓与跟踪止盈全平。
|
||||||
package logic
|
package logic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.apinb.com/quant/coin/internal/config"
|
"git.apinb.com/quant/coin/internal/config"
|
||||||
"git.apinb.com/quant/coin/internal/impl"
|
|
||||||
"git.apinb.com/quant/coin/internal/models"
|
"git.apinb.com/quant/coin/internal/models"
|
||||||
"github.com/adshao/go-binance/v2"
|
"github.com/adshao/go-binance/v2"
|
||||||
"github.com/shopspring/decimal"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 以下为可调参数,改数值即可调整策略行为(改后重新编译运行)。
|
// 以下为可调参数,改数值即可调整策略行为(改后重新编译运行)。
|
||||||
@@ -21,40 +19,61 @@ const (
|
|||||||
// pollInterval 两次完整轮询之间的间隔(拉余额、价格、判单、写库)
|
// pollInterval 两次完整轮询之间的间隔(拉余额、价格、判单、写库)
|
||||||
pollInterval = 60 * time.Second
|
pollInterval = 60 * time.Second
|
||||||
|
|
||||||
// buyQuoteUSDT 每次市价买入使用的报价资产数量(USDT)
|
// maxDipAdds 相对当前均价最多加仓次数(不含首仓)
|
||||||
buyQuoteUSDT = 100.0
|
maxDipAdds = 4
|
||||||
// dipPct 相对成本价的下跌比例,跌破则触发加仓(若本波未锁)
|
// profitArmPct 浮盈达到该比例后进入「跟踪高点」:继续上涨不平仓,仅从高点回撤 trailPullbackPct 时全平
|
||||||
dipPct = 0.10
|
profitArmPct = 0.05
|
||||||
// profitPct 相对成本价的上涨比例,涨破则触发减仓(若本波未锁)
|
// trailPullbackPct 从跟踪期内最高价回撤本比例则触发市价全仓卖出
|
||||||
profitPct = 0.10
|
trailPullbackPct = 0.005
|
||||||
// sellFraction 上涨触发时,卖出当前可用基础资产的比例
|
|
||||||
sellFraction = 0.20
|
|
||||||
|
|
||||||
// minHoldUSDT 持仓市值低于此值(USDT)视为「无仓」,会先建首笔 100U
|
// minHoldUSDT 持仓市值低于此值(USDT)视为「无仓」,会按配置 OrderQty 市价买基础币建首笔
|
||||||
minHoldUSDT = 10.0
|
minHoldUSDT = 10.0
|
||||||
// minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional)
|
// minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional)
|
||||||
minSellNotional = 10.0
|
minSellNotional = 10.0
|
||||||
|
|
||||||
// dipRecoverPct 超跌加仓后,现价需高于「成本×(1+本值)」才解除加仓锁,避免同一低位反复买
|
// dipRecoverPct 超跌加仓后,现价需高于「成本×(1+本值)」才解除加仓锁,避免同一低位反复买
|
||||||
dipRecoverPct = 0.03
|
dipRecoverPct = 0.03
|
||||||
// rallyResetHighPct 冲高减仓后,现价需低于「成本×(1+本值)」才解除减仓锁,避免同一高位反复卖
|
// buyReboundPct 开仓/加仓前须相对阶段低点反弹超过本比例才市价买入(持续创新低则不买)
|
||||||
rallyResetHighPct = 0.06
|
buyReboundPct = 0.0039
|
||||||
)
|
)
|
||||||
|
|
||||||
// spotSymbol 把「账户里的资产名」和「交易对」绑在一起,避免硬编码散落各处。
|
// spotSymbol 单标的运行时视图:基础资产、交易对、每笔买入的基础币数量(来自配置 SpotWatchList)。
|
||||||
type spotSymbol struct {
|
type spotSymbol struct {
|
||||||
Base string // 如 BTC
|
Base string
|
||||||
Symbol string // 如 BTCUSDT
|
Symbol string // 如 BTCUSDT
|
||||||
|
OrderQty float64 // 开仓与加仓共用(枚)
|
||||||
}
|
}
|
||||||
|
|
||||||
// watchList 策略监控的标的列表;增删币种时改此处即可。
|
// spotWatchesFromConfig 从 Spec.SpotWatchList 解析有效项;Symbol 空或 OrderQty≤0 的条目会被跳过。
|
||||||
var watchList = []spotSymbol{
|
func spotWatchesFromConfig() []spotSymbol {
|
||||||
{"BTC", "BTCUSDT"},
|
items := config.Spec.SpotWatchList
|
||||||
{"ETH", "ETHUSDT"},
|
out := make([]spotSymbol, 0, len(items))
|
||||||
{"SOL", "SOLUSDT"},
|
for _, it := range items {
|
||||||
{"XRP", "XRPUSDT"},
|
sym := strings.TrimSpace(it.Symbol)
|
||||||
|
if sym == "" || it.OrderQty <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
us := strings.ToUpper(sym)
|
||||||
|
out = append(out, spotSymbol{
|
||||||
|
Base: spotBaseFromSymbol(us),
|
||||||
|
Symbol: us,
|
||||||
|
OrderQty: it.OrderQty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spotBaseFromSymbol 由 USDT 现货交易对推导基础资产名(如 BTCUSDT → BTC);非 *USDT 后缀则整体大写作为键。
|
||||||
|
func spotBaseFromSymbol(symbolUpper string) string {
|
||||||
|
if strings.HasSuffix(symbolUpper, "USDT") && len(symbolUpper) > 4 {
|
||||||
|
return strings.TrimSuffix(symbolUpper, "USDT")
|
||||||
|
}
|
||||||
|
return symbolUpper
|
||||||
|
}
|
||||||
|
|
||||||
|
// dipAddDrawdowns[k] 为已完成 k 次加仓后,下一次加仓须达到的相对当前加权均价的跌幅(第 1 次 5%、第 2 次 15%…)。
|
||||||
|
var dipAddDrawdowns = [...]float64{0.05, 0.15, 0.30, 0.50}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
portfolioMu sync.Mutex // 保护 portfolio 与数据库写入,避免并发轮询(若以后拆协程)
|
portfolioMu sync.Mutex // 保护 portfolio 与数据库写入,避免并发轮询(若以后拆协程)
|
||||||
portfolio = models.NewSpotPortfolioSnapshot()
|
portfolio = models.NewSpotPortfolioSnapshot()
|
||||||
@@ -86,6 +105,10 @@ func runBinanceSpotStrategy() {
|
|||||||
log.Printf("logic: Binance 账户未开启现货交易权限")
|
log.Printf("logic: Binance 账户未开启现货交易权限")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if len(spotWatchesFromConfig()) == 0 {
|
||||||
|
log.Printf("logic: SpotWatchList 未配置或无效,跳过现货策略")
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Printf("logic: Binance 已连接,CanTrade=%v", acct.CanTrade)
|
log.Printf("logic: Binance 已连接,CanTrade=%v", acct.CanTrade)
|
||||||
|
|
||||||
if err := loadPortfolio(); err != nil {
|
if err := loadPortfolio(); err != nil {
|
||||||
@@ -105,82 +128,20 @@ func runBinanceSpotStrategy() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshStepSizes 从 exchangeInfo 拉取各 symbol 的 LOT_SIZE.stepSize,供卖出数量格式化。
|
// spotTick 单次轮询:拉账户 + 批量现价 → 对每个配置标的执行 processOne → 持久化到库。
|
||||||
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 {
|
func spotTick(ctx context.Context, client *binance.Client) 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 := client.NewGetAccountService().Do(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
symbols := make([]string, len(watchList))
|
symbols := make([]string, len(watch))
|
||||||
for i, w := range watchList {
|
for i, w := range watch {
|
||||||
symbols[i] = w.Symbol
|
symbols[i] = w.Symbol
|
||||||
}
|
}
|
||||||
prices, err := client.NewListPricesService().Symbols(symbols).Do(ctx)
|
prices, err := client.NewListPricesService().Symbols(symbols).Do(ctx)
|
||||||
@@ -199,7 +160,7 @@ func spotTick(ctx context.Context, client *binance.Client) error {
|
|||||||
portfolioMu.Lock()
|
portfolioMu.Lock()
|
||||||
defer portfolioMu.Unlock()
|
defer portfolioMu.Unlock()
|
||||||
|
|
||||||
for _, w := range watchList {
|
for _, w := range watch {
|
||||||
px, ok := priceBySymbol[w.Symbol]
|
px, ok := priceBySymbol[w.Symbol]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
@@ -233,147 +194,60 @@ func processOne(ctx context.Context, client *binance.Client, balances []binance.
|
|||||||
st := getOrCreateState(w.Base, w.Symbol)
|
st := getOrCreateState(w.Base, w.Symbol)
|
||||||
positionUSDT := free * price
|
positionUSDT := free * price
|
||||||
|
|
||||||
// ---------- 分支一:视为空仓,首笔市价买 100 USDT ----------
|
if positionUSDT >= minHoldUSDT {
|
||||||
if positionUSDT < minHoldUSDT {
|
st.OpenReboundLow = 0
|
||||||
st.DipLegLocked = false
|
}
|
||||||
st.RallyLegLocked = false
|
|
||||||
usdtFree, err := balanceFree(balances, "USDT")
|
if positionUSDT < minHoldUSDT {
|
||||||
if err != nil {
|
st.DipAddsDone = 0
|
||||||
return err
|
st.DipLegLocked = false
|
||||||
}
|
st.RallyLegLocked = false
|
||||||
if usdtFree < buyQuoteUSDT {
|
resetSpotTrail(st)
|
||||||
return errors.New("USDT 余额不足,无法建仓 100U")
|
if !spotReboundReady(&st.OpenReboundLow, price) {
|
||||||
}
|
return nil
|
||||||
order, err := client.NewCreateOrderService().
|
}
|
||||||
Symbol(w.Symbol).
|
err = trySpotInitialEntry(ctx, client, balances, w, st, price)
|
||||||
Side(binance.SideTypeBuy).
|
if err == nil {
|
||||||
Type(binance.OrderTypeMarket).
|
st.OpenReboundLow = 0
|
||||||
QuoteOrderQty(strconv.FormatFloat(buyQuoteUSDT, 'f', 2, 64)).
|
}
|
||||||
NewOrderRespType(binance.NewOrderRespTypeFULL).
|
return err
|
||||||
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 {
|
if st.AvgCostUSDT <= 0 {
|
||||||
st.AvgCostUSDT = price
|
st.AvgCostUSDT = price
|
||||||
}
|
}
|
||||||
st.Quantity = free
|
st.Quantity = free
|
||||||
|
|
||||||
// ---------- 分支二:相对成本下跌 dipPct,且本波未加仓过 → 再买 100 USDT ----------
|
if err := trySpotDipAdd(ctx, client, balances, w, price, st); err != nil {
|
||||||
dipLine := st.AvgCostUSDT * (1 - dipPct)
|
return err
|
||||||
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) {
|
if st.DipLegLocked && price >= st.AvgCostUSDT*(1+dipRecoverPct) {
|
||||||
st.DipLegLocked = false
|
st.DipLegLocked = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 分支三:相对成本上涨 profitPct,且本波未减仓过 → 卖出 free 的 sellFraction ----------
|
if err := trySpotRallySell(ctx, client, w, price, st, free); err != nil {
|
||||||
profitLine := st.AvgCostUSDT * (1 + profitPct)
|
return err
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyBuyFill 根据市价买单成交回报更新加权成本与数量(首笔建仓与加仓共用)。
|
// resetSpotTrail 清除跟踪止盈状态(空仓、加仓后成本变化时调用)。
|
||||||
func applyBuyFill(st *models.SpotPosition, order *binance.CreateOrderResponse) {
|
func resetSpotTrail(st *models.SpotPosition) {
|
||||||
execQty, _ := strconv.ParseFloat(order.ExecutedQuantity, 64)
|
st.TrailArmed = false
|
||||||
quote, _ := strconv.ParseFloat(order.CummulativeQuoteQuantity, 64)
|
st.TrailPeakUSDT = 0
|
||||||
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。
|
// spotReboundReady 用 *latch 跟踪阶段低价:创新低则只下移 latch 不通过;现价相对 latch 反弹≥buyReboundPct 时返回 true。
|
||||||
func formatQtyForSell(qty float64, stepSize string) (string, bool, error) {
|
// *latch≤0 时锚定 latch=price 并返回 false(首根 K 不直接买)。
|
||||||
if qty <= 0 {
|
func spotReboundReady(latch *float64, price float64) bool {
|
||||||
return "", false, nil
|
if *latch <= 0 {
|
||||||
|
*latch = price
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
step, err := decimal.NewFromString(stepSize)
|
if price < *latch {
|
||||||
if err != nil {
|
*latch = price
|
||||||
return "", false, err
|
return false
|
||||||
}
|
}
|
||||||
q := decimal.NewFromFloat(qty)
|
return price >= *latch*(1+buyReboundPct)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
79
internal/logic/spot_binance_account.go
Normal file
79
internal/logic/spot_binance_account.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"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,供卖出数量格式化。
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
95
internal/logic/spot_binance_close.go
Normal file
95
internal/logic/spot_binance_close.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.apinb.com/quant/coin/internal/models"
|
||||||
|
"github.com/adshao/go-binance/v2"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trySpotRallySell 跟踪止盈:浮盈≥profitArmPct 后记录阶段最高价,不在该涨幅直接平仓;
|
||||||
|
// 仅当现价从本轮高点回撤≥trailPullbackPct 时市价卖出全部可用基础资产。
|
||||||
|
func trySpotRallySell(ctx context.Context, client *binance.Client, w spotSymbol, price float64, st *models.SpotPosition, free float64) error {
|
||||||
|
cost := st.AvgCostUSDT
|
||||||
|
if cost <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
armLine := cost * (1 + profitArmPct)
|
||||||
|
|
||||||
|
if !st.TrailArmed {
|
||||||
|
if price >= armLine {
|
||||||
|
st.TrailArmed = true
|
||||||
|
st.TrailPeakUSDT = price
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if price > st.TrailPeakUSDT {
|
||||||
|
st.TrailPeakUSDT = price
|
||||||
|
}
|
||||||
|
sellLine := st.TrailPeakUSDT * (1 - trailPullbackPct)
|
||||||
|
if price >= sellLine {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
qtyStr, ok, err := formatQtyToLotStep(free, spotLotStep(w.Symbol))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
peak := st.TrailPeakUSDT
|
||||||
|
resetSpotTrail(st)
|
||||||
|
log.Printf("logic: %s 从跟踪高点 %.6f 回撤≥%.1f%% 全平, 卖出数量 %s", w.Symbol, peak, trailPullbackPct*100, qtyStr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// spotLotStep 返回交易对 LOT_SIZE 步长;未加载 exchangeInfo 时用保守默认。
|
||||||
|
func spotLotStep(symbol string) string {
|
||||||
|
if s := stepSizes[symbol]; s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "0.00001"
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatQtyToLotStep 将数量按 stepSize 向下取整,满足 Binance LOT_SIZE(买卖共用);过小则返回 ok=false。
|
||||||
|
func formatQtyToLotStep(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
|
||||||
|
}
|
||||||
121
internal/logic/spot_binance_open.go
Normal file
121
internal/logic/spot_binance_open.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package logic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.apinb.com/quant/coin/internal/models"
|
||||||
|
"github.com/adshao/go-binance/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trySpotInitialEntry 视为空仓时按配置 OrderQty(基础币)市价买入,数量按 LOT_SIZE 向下取整。
|
||||||
|
func trySpotInitialEntry(ctx context.Context, client *binance.Client, balances []binance.Balance, w spotSymbol, st *models.SpotPosition, price float64) error {
|
||||||
|
want := w.OrderQty
|
||||||
|
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)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// trySpotDipAdd 相对当前均价达到第 (DipAddsDone+1) 档跌幅(5%/15%/30%/50%)且未锁波段时,先等反弹 buyReboundPct 再按 OrderQty 买入;最多加仓 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
|
||||||
|
}
|
||||||
|
cost := st.AvgCostUSDT
|
||||||
|
if cost <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
draw := dipAddDrawdowns[st.DipAddsDone]
|
||||||
|
dipLine := cost * (1 - draw)
|
||||||
|
if price > dipLine {
|
||||||
|
st.DipReboundLow = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if st.DipLegLocked {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !spotReboundReady(&st.DipReboundLow, price) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
want := w.OrderQty
|
||||||
|
order, qtyStr, err := executeSpotMarketBuy(ctx, client, balances, w, price)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applyBuyFill(st, order)
|
||||||
|
st.DipAddsDone++
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
usdtFree, err := balanceFree(balances, "USDT")
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
est := parseFloat(qtyStr) * price
|
||||||
|
if usdtFree < est {
|
||||||
|
return nil, "", fmt.Errorf("USDT 余额不足,约需 %.2f USDT(数量 %s × 价 %.6f)", est, qtyStr, price)
|
||||||
|
}
|
||||||
|
order, err := client.NewCreateOrderService().
|
||||||
|
Symbol(w.Symbol).
|
||||||
|
Side(binance.SideTypeBuy).
|
||||||
|
Type(binance.OrderTypeMarket).
|
||||||
|
Quantity(qtyStr).
|
||||||
|
NewOrderRespType(binance.NewOrderRespTypeFULL).
|
||||||
|
Do(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return order, qtyStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// spotBuyQtyString 将配置的 OrderQty 按交易对 LOT_SIZE 向下取整为下单字符串;want 为配置原值。
|
||||||
|
func spotBuyQtyString(w spotSymbol) (qtyStr string, want float64, err error) {
|
||||||
|
want = w.OrderQty
|
||||||
|
step := spotLotStep(w.Symbol)
|
||||||
|
ok := false
|
||||||
|
qtyStr, ok, err = formatQtyToLotStep(want, step)
|
||||||
|
if err != nil {
|
||||||
|
return "", want, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", want, fmt.Errorf("%s: OrderQty=%g 按 LOT_SIZE(%s) 取整后为 0,请调大数量或检查交易对", w.Symbol, want, step)
|
||||||
|
}
|
||||||
|
return qtyStr, want, 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
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"git.apinb.com/bsm-sdk/core/database"
|
"git.apinb.com/bsm-sdk/core/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SpotPosition 现货策略在库中的一行:按 BaseAsset 唯一,对应 watchList 里每个基础资产。
|
// SpotPosition 现货策略在库中的一行:按 BaseAsset 唯一,对应配置 SpotWatchList 里每个基础资产。
|
||||||
type SpotPosition struct {
|
type SpotPosition struct {
|
||||||
ID uint `gorm:"primarykey"`
|
ID uint `gorm:"primarykey"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
@@ -21,10 +21,20 @@ type SpotPosition struct {
|
|||||||
AvgCostUSDT float64 `gorm:"column:avg_cost_usdt;not null;default:0"`
|
AvgCostUSDT float64 `gorm:"column:avg_cost_usdt;not null;default:0"`
|
||||||
// Quantity 策略侧同步的持仓数量(枚)
|
// Quantity 策略侧同步的持仓数量(枚)
|
||||||
Quantity float64 `gorm:"not null;default:0"`
|
Quantity float64 `gorm:"not null;default:0"`
|
||||||
|
// DipAddsDone 本轮持仓已完成的超跌加仓次数(最多 4);空仓时清零
|
||||||
|
DipAddsDone int `gorm:"column:dip_adds_done;not null;default:0"`
|
||||||
// DipLegLocked 超跌加仓波段锁
|
// DipLegLocked 超跌加仓波段锁
|
||||||
DipLegLocked bool `gorm:"column:dip_leg_locked;not null;default:false"`
|
DipLegLocked bool `gorm:"column:dip_leg_locked;not null;default:false"`
|
||||||
// RallyLegLocked 冲高减仓波段锁
|
// RallyLegLocked 历史字段,策略已不再使用,保留列以兼容已有库
|
||||||
RallyLegLocked bool `gorm:"column:rally_leg_locked;not null;default:false"`
|
RallyLegLocked bool `gorm:"column:rally_leg_locked;not null;default:false"`
|
||||||
|
// TrailArmed 浮盈已达 profitArmPct,正在跟踪高点(此阶段不因达线而直接平仓)
|
||||||
|
TrailArmed bool `gorm:"column:trail_armed;not null;default:false"`
|
||||||
|
// TrailPeakUSDT 跟踪期内的最高价(USDT/枚);未武装或未更新时为 0
|
||||||
|
TrailPeakUSDT float64 `gorm:"column:trail_peak_usdt;not null;default:0"`
|
||||||
|
// OpenReboundLow 空仓等首买时跟踪的阶段低价;≤0 表示尚未锚定本轮观察
|
||||||
|
OpenReboundLow float64 `gorm:"column:open_rebound_low;not null;default:0"`
|
||||||
|
// DipReboundLow 已跌破加仓线后跟踪的阶段低价;≤0 表示未进入等反弹状态(离开超跌区会清零)
|
||||||
|
DipReboundLow float64 `gorm:"column:dip_rebound_low;not null;default:0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
51
intro.md
Normal file
51
intro.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# 策略说明
|
||||||
|
|
||||||
|
本策略在若干现货标的上循环执行,只做一件事:**在相对便宜时分批买进,在明显转强后一次性卖光**,中间用规则避免「一路跌还一路买」和「刚涨一点就卖光」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 什么时候算「没有仓位」
|
||||||
|
|
||||||
|
当手里该币换算成资金后,**整体很轻**时,视为空仓。此后按下面的规则寻找第一次买进的机会。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一次买进(空仓 → 有仓)
|
||||||
|
|
||||||
|
不追正在往下走的行情:先记住这段观察里见过的**最低价**,只要价格还在创新低,就**不买**。
|
||||||
|
|
||||||
|
只有当价格从这段最低价**往上弹起超过约千分之四**(约 0.39%)时,才认为短期跌势缓和,此时用你事先定好的**固定枚数**做一次市价买进,完成首仓。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下跌时怎么加仓
|
||||||
|
|
||||||
|
以当前持仓的**平均成本**为参照,分四档加深跌幅,**最多加四次仓**:
|
||||||
|
|
||||||
|
1. 第一次加仓:价比平均成本**低约 5%** 时,进入该档的观察;
|
||||||
|
2. 第二次:**低约 15%**;
|
||||||
|
3. 第三次:**低约 30%**;
|
||||||
|
4. 第四次:**低约 50%**。
|
||||||
|
|
||||||
|
每一档都和首买一样:**不在创新低的过程中买**,要等相对该档观察期内的低点**反弹超过约 0.39%** 才下单;每次加仓的枚数与首买相同。
|
||||||
|
|
||||||
|
加完一次仓之后,会有一段时间**锁在这一档**:要先把价格拉回到比平均成本**高出约 3%** 以上,才允许去等下一档更深的下跌。这样避免在同一个低位附近反复买。
|
||||||
|
|
||||||
|
四档都用完后,**不再加仓**,只保留止盈规则。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 上涨时怎么卖出(全部卖光)
|
||||||
|
|
||||||
|
当浮盈达到**大约 5%** 时,并不马上卖,而是开始**记住这段上涨里见过的最高价**:价格若继续涨,就把这个「高点」往上抬。
|
||||||
|
|
||||||
|
只要还在创新高或贴着高位,就**不卖**。只有当价格从这段**最高价往下跌回超过约千分之五**(约 0.5%)时,才认为涨势可能告一段落,此时**一次性把当前能卖的基础币全部市价卖掉**,相当于这一轮清仓。
|
||||||
|
|
||||||
|
清仓后,该标的重新按「没有仓位」处理,下次再买又从「等反弹再首买」开始。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 小结
|
||||||
|
|
||||||
|
- **买**:等短期止跌(小反弹)再买;空仓首买一次;下跌按 5% → 15% → 30% → 50% 四档加深,每档同样等小反弹,且加过仓后要涨离成本一段才能等下一档;一共最多加四次仓。
|
||||||
|
- **卖**:先等到明显浮盈并走出一段上行,再从本轮高点小幅回撤时**全部卖光**,不在刚摸到 5% 盈利线就立刻卖。
|
||||||
Reference in New Issue
Block a user