This commit is contained in:
2026-05-11 11:07:01 +08:00
parent 1fa500921b
commit e8e97c7047
7 changed files with 192 additions and 327 deletions

View File

@@ -8,9 +8,10 @@ import (
"git.apinb.com/quant/coin/internal/impl"
"git.apinb.com/quant/coin/internal/models"
"github.com/adshao/go-binance/v2"
"github.com/shopspring/decimal"
)
// loadPortfolio 从 GORM 读入全表 spot_positions填充内存 map键为 BaseAsset)。
// loadPortfolio 从 GORM 读入 status=0 的 spot_positions填充内存 map键为 Symbol如 BTCUSDT)。
func loadPortfolio() error {
portfolioMu.Lock()
defer portfolioMu.Unlock()
@@ -19,14 +20,14 @@ func loadPortfolio() error {
return nil
}
var rows []models.SpotPosition
if err := impl.DBService.Find(&rows).Error; err != nil {
if err := impl.DBService.Where("status = 0").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
portfolio.Positions[p.Symbol] = p
}
return nil
}
@@ -46,13 +47,37 @@ func RefreshAccount() error {
}
accountMu.Lock()
defer accountMu.Unlock()
next := make(map[string]float64)
for _, b := range acct.Balances {
free, err := strconv.ParseFloat(b.Free, 64)
if err != nil {
if b.Asset != "USDT" {
continue
}
account[b.Asset] = free
free, err := strconv.ParseFloat(b.Free, 64)
if err != nil {
break
}
next["USDT"] = formatQty(free, 8)
break
}
// 按交易对键入可用基础币数量(与 trySpotRallySell 中 GetAccount(pos.Symbol) 一致)
for _, sym := range Symbols {
cfg := SymbolInfos[sym]
if cfg == nil {
continue
}
var baseFree float64
for _, b := range acct.Balances {
if b.Asset != cfg.BaseAsset {
continue
}
if v, err := strconv.ParseFloat(b.Free, 64); err == nil {
baseFree = v
}
break
}
next[sym] = formatQty(baseFree, cfg.BaseAssetPrecision)
}
account = next
return nil
}
@@ -66,6 +91,11 @@ func GetAccount(asset string) float64 {
return free
}
func formatQty(qty float64, precision int) float64 {
d := decimal.NewFromFloat(qty).Round(int32(precision))
return d.InexactFloat64()
}
// balanceFree 从账户余额列表里解析某资产的可用数量Free 为字符串)。
func balanceFree(balances []binance.Balance, asset string) (float64, error) {
for _, b := range balances {
@@ -75,19 +105,3 @@ func balanceFree(balances []binance.Balance, asset string) (float64, error) {
}
return 0, nil
}
// savePortfolioLocked 将内存中各 SpotPosition 写回数据库。调用方须已持有 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
}

60
internal/logic/close.go Normal file
View File

@@ -0,0 +1,60 @@
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"
)
var (
CloseCache = make(map[string]float64)
)
// trySpotRallySell 跟踪止盈浮盈≥profitArmPct 后记录阶段峰值 PnL继续上涨则刷新峰值
// 当未实现盈亏较峰值回落≥gridStartPct 时市价卖出(数量来自 RefreshAccount 写入的 account[sym])。
func trySpotRallySell(pos *models.SpotPosition, pnlPct float64) error {
if val, ok := CloseCache[pos.Symbol]; !ok || val == 0 {
CloseCache[pos.Symbol] = pnlPct
return nil
}
if pnlPct >= CloseCache[pos.Symbol] {
CloseCache[pos.Symbol] = pnlPct
return nil
}
if CloseCache[pos.Symbol]-pnlPct < gridStartPct {
return nil
}
// 取到持仓数量
qty := GetAccount(pos.Symbol)
qtyStr := strconv.FormatFloat(qty, 'f', -1, 64)
ctx := context.Background()
order, err := BinanceClient.NewCreateOrderService().
Symbol(pos.Symbol).
Side(binance.SideTypeSell).
Type(binance.OrderTypeMarket).
Quantity(qtyStr).
NewOrderRespType(binance.NewOrderRespTypeRESULT).
Do(ctx)
if err != nil {
return err
}
orderPrice, err := strconv.ParseFloat(order.Price, 64)
pos.SellQuantity += parseFloat(order.ExecutedQuantity)
pos.SellPrice = (pos.SellQuantity*pos.SellPrice + parseFloat(order.ExecutedQuantity)*orderPrice) / pos.SellQuantity
pos.Status = 1
impl.DBService.Save(pos)
// 更新缓存
loadPortfolio()
CloseCache[pos.Symbol] = 0
return nil
}

View File

@@ -48,9 +48,9 @@ func CreateNewSpotPosition(symbol string) error {
}
position := &models.SpotPosition{
Symbol: symbol,
Quantity: parseFloat(order.ExecutedQuantity),
AvgCostUSDT: parseFloat(order.Price),
Symbol: symbol,
BuyQuantity: parseFloat(order.ExecutedQuantity),
BuyCostPrice: parseFloat(order.Price),
}
if err := impl.DBService.Create(position).Error; err != nil {
@@ -65,20 +65,22 @@ func CreateNewSpotPosition(symbol string) error {
}
func AddSpotPosition(pos *models.SpotPosition, pnlPct, price float64) error {
if _, ok := AddCache[pos.Symbol]; !ok {
if val, ok := AddCache[pos.Symbol]; !ok || val == 0 {
AddCache[pos.Symbol] = pnlPct
return nil
}
if pnlPct <= AddCache[pos.Symbol] {
if pnlPct >= AddCache[pos.Symbol] {
AddCache[pos.Symbol] = pnlPct
return nil
}
if AddCache[pos.Symbol]-pnlPct < gridStartPct {
return nil
}
// 加仓
qty, err := CalculateQtyByPrice(pos.Symbol, price)
if err != nil {
return err
qty := CalculateQtyByPrice(pos.Symbol, price)
if qty == "" {
return errors.New("数量不足")
}
ctx := context.Background()
order, err := BinanceClient.NewCreateOrderService().
@@ -94,16 +96,16 @@ func AddSpotPosition(pos *models.SpotPosition, pnlPct, price float64) error {
// 更新数据库
orderPrice, err := strconv.ParseFloat(order.Price, 64)
pos.Quantity += parseFloat(order.ExecutedQuantity)
pos.AvgCostUSDT = (pos.Quantity*pos.AvgCostUSDT + parseFloat(order.ExecutedQuantity)*orderPrice) / pos.Quantity
pos.BuyQuantity += parseFloat(order.ExecutedQuantity)
pos.BuyCostPrice = (pos.BuyQuantity*pos.BuyCostPrice + parseFloat(order.ExecutedQuantity)*orderPrice) / pos.BuyQuantity
pos.DipAddsDone++
pos.DipLegLocked = true
pos.DipReboundLow = 0
impl.DBService.Save(pos)
// 更新缓存
loadPortfolio()
AddCache[pos.Symbol] = 0
return nil
}
@@ -123,8 +125,11 @@ func CalculateQty(symbol string) (string, error) {
return "", errors.New("交易对不存在")
}
want := InvestUsdt[cfg.Symbol] / price
step := cfg.LotSizeFilter().StepSize
qtyStr, ok, err := formatQtyToLotStep(want, step)
lot := cfg.LotSizeFilter()
if lot == nil {
return "", errors.New("交易对无 LOT_SIZE 规则")
}
qtyStr, ok, err := formatQtyToLotStep(want, lot.StepSize)
if err != nil {
return "", err
}
@@ -134,16 +139,19 @@ func CalculateQty(symbol string) (string, error) {
return qtyStr, nil
}
func CalculateQtyByPrice(symbol string, price float64) (string, error) {
func CalculateQtyByPrice(symbol string, price float64) string {
cfg := getSymbolInfo(symbol)
if cfg == nil || price <= 0 {
return ""
}
want := InvestUsdt[symbol] / price
step := cfg.LotSizeFilter().StepSize
qtyStr, ok, err := formatQtyToLotStep(want, step)
if err != nil {
return "", err
lot := cfg.LotSizeFilter()
if lot == nil {
return ""
}
if !ok {
return "", errors.New("数量不足")
qtyStr, ok, err := formatQtyToLotStep(want, lot.StepSize)
if err != nil || !ok {
return ""
}
return qtyStr, nil
return qtyStr
}

View File

@@ -2,10 +2,12 @@ package logic
import (
"context"
"log"
"strconv"
"time"
"git.apinb.com/quant/coin/internal/models"
"github.com/shopspring/decimal"
)
// 以下为可调参数,改数值即可调整策略行为(改后重新编译运行)。
@@ -15,26 +17,10 @@ const (
// maxDipAdds 相对当前均价最多加仓次数(不含首仓)
maxDipAdds = 4
// profitArmPct 浮盈达到该比例后进入「跟踪高点」:继续上涨不平仓,仅从高点回撤 trailPullbackPct 时全平
// profitArmPct 浮盈达到该比例后进入「跟踪峰值 PnL」从峰值回落 gridStartPct 后再清仓
profitArmPct = 0.05
// trailPullbackPct 从跟踪期内最高价回撤本比例则触发市价全仓卖出
trailPullbackPct = 0.005
// minHoldUSDT 持仓市值默认「无仓」判断的全局名义下限USDT单笔 OrderQtyUsdt 接近该值时会用 spotHoldingThresholdUSDT 放宽,避免 LOT 取整后略低于本值被误判空仓。
minHoldUSDT = 10.0
// minOrderQtyUsdt SpotWatchList 单笔开仓/加仓配置的 USDT 名义下限(与常见 minNotional 对齐)。
minOrderQtyUsdt = 10.0
// minSellNotional 单笔卖出名义价值下限,低于则不下单(贴近交易所 minNotional
minSellNotional = 10.0
// dipRecoverPct 超跌加仓后,现价需高于「成本×(1+本值)」才解除加仓锁,避免同一低位反复买
dipRecoverPct = 0.03
// buyReboundPct 开仓/加仓前须相对阶段低点反弹超过本比例才市价买入(持续创新低则不买)
buyReboundPct = 0.0039
// spotImmediateInitialOpen 为 true 时,仅当策略侧从未有过持仓(成本与数量均为 0时跳过「先锚定再等反弹」
// 启动后首轮即可市价开首仓;曾经建仓后又全平的标的仍须等反弹后再进,避免刚卖立刻买回。
spotImmediateInitialOpen = true
// gridStartPct 加仓/清仓前相对缓存极值的最小回撤幅度(与未实现盈亏比例同量纲),过滤噪声
gridStartPct = 0.005
)
// dipAddDrawdowns[k] 为已完成 k 次加仓后,下一次加仓须达到的相对当前加权均价的跌幅(第 1 次 5%、第 2 次 15%…)。
@@ -45,6 +31,10 @@ func Run(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
if err := RefreshAccount(); err != nil {
log.Printf("logic: Run 刷新账户余额失败: %v", err)
}
// 1. 最新价格ListPrices 与标的 ticker
prices, err := BinanceClient.NewListPricesService().Symbols(Symbols).Do(ctx)
if err != nil {
@@ -59,40 +49,69 @@ func Run(ctx context.Context) error {
priceBySymbol[p.Symbol] = v
}
portfolioMu.Lock()
defer portfolioMu.Unlock()
// 不在此持 portfolioMuGetPortfolio / 下单路径里的 loadPortfolio 也会抢同一把锁,会死锁。
portfolio := GetPortfolio()
for symbol, pos := range portfolio {
price, ok := priceBySymbol[symbol]
if !ok {
continue
}
// 计算加仓档位
var draw float64
if pos.DipAddsDone <= maxDipAdds {
draw = dipAddDrawdowns[pos.DipAddsDone-1] // 第 1 次加仓对应跌幅阈值:**5% / 15% / 30% / 50%**`dipAddDrawdowns`)。
} else {
// 计算PNL盈利侧始终可止盈加仓仅在未打满档时判断
pnl := spotUnrealizedPnLPct(pos, price)
if pnl >= profitArmPct {
trySpotRallySell(pos, pnl)
continue
}
// 计算PNL 并判断是否需要加仓或清仓
pnl := spotUnrealizedPnLPct(pos, price)
if pnl >= profitArmPct {
// 盈利侧考虑清仓
trySpotRallySell(ctx, symbol, price, pos, pos.Quantity*price)
} else if pnl < -draw {
// 亏损侧考虑加仓
if pos.DipAddsDone >= maxDipAdds {
continue
}
draw := dipAddDrawdowns[pos.DipAddsDone]
if pnl < -draw {
AddSpotPosition(pos, pnl, price)
}
}
// 配置里有、但库中无 status=0 持仓时补首仓(止盈/删档后仅靠 boot 一次不够)
pf := GetPortfolio()
for _, sym := range Symbols {
if _, ok := pf[sym]; ok {
continue
}
if err := CreateNewSpotPosition(sym); err != nil {
log.Printf("logic: %s 无有效持仓,补首仓失败: %v", sym, err)
}
}
return nil
}
// spotUnrealizedPnLPct 相对持仓均价的未实现盈亏比例;(现价-成本)/成本;无有效成本时返回 0。
func spotUnrealizedPnLPct(st *models.SpotPosition, price float64) float64 {
if st == nil || st.AvgCostUSDT <= 0 || price <= 0 {
if st == nil || st.BuyCostPrice <= 0 || price <= 0 {
return 0
}
return (price - st.AvgCostUSDT) / st.AvgCostUSDT
return (price - st.BuyCostPrice) / st.BuyCostPrice
}
// 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
}

View File

@@ -1,103 +0,0 @@
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, st *models.SpotPosition, price 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 {
log.Printf("logic: %s 跟踪止盈回撤触发但可卖数量按 LOT 步长取整为 0free=%.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 := BinanceClient.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)
markSpotPortfolioDirty()
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
}

View File

@@ -1,125 +0,0 @@
package logic
import (
"context"
"fmt"
"log"
"strconv"
"git.apinb.com/quant/coin/internal/models"
"github.com/adshao/go-binance/v2"
)
// trySpotInitialEntry 视为空仓时按配置 OrderQtyUsdtUSDT 名义)市价买入,数量按现价换算后按 LOT_SIZE 向下取整。
func trySpotInitialEntry(ctx context.Context, balances []binance.Balance, st *models.SpotPosition, price float64) error {
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 (配置 %.2f USDT), 成交均价约 %.4f", w.Symbol, buyReboundPct*100, qtyStr, wantUsdt, st.AvgCostUSDT)
return nil
}
// trySpotDipAdd 相对当前均价达到第 (DipAddsDone+1) 档跌幅5%/15%/30%/50%)且未锁波段时,先等反弹 buyReboundPct 再按 OrderQtyUsdt 买入;最多加仓 maxDipAdds 次。
func trySpotDipAdd(ctx context.Context, 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
}
wantUsdt := w.OrderQtyUsdt
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 (配置 %.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, balances []binance.Balance, w spotSymbol, price float64) (*binance.CreateOrderResponse, string, error) {
qtyStr, _, err := spotBuyQtyString(w, price)
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 将 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)
if err != nil {
return "", want, err
}
if !ok {
return "", want, fmt.Errorf("%s: OrderQtyUsdt=%g USDT 按现价与 LOT_SIZE(%s) 取整后为 0请调大 OrderQtyUsdt≥%.0f)或检查交易对", w.Symbol, w.OrderQtyUsdt, step, minOrderQtyUsdt)
}
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
markSpotPortfolioDirty()
}

View File

@@ -13,28 +13,20 @@ type SpotPosition struct {
CreatedAt time.Time
UpdatedAt time.Time
// BaseAsset 基础资产代码,如 BTC唯一索引用于 upsert 与内存 map 的键
BaseAsset string `gorm:"uniqueIndex:uk_spot_base_asset;size:32;not null"`
// Symbol 交易对,如 BTCUSDT
Symbol string `gorm:"size:32;not null"`
// AvgCostUSDT 加权平均持仓成本USDT/枚
AvgCostUSDT float64 `gorm:"column:avg_cost_usdt;not null;default:0"`
// AvgCostUSDT 加权平均持仓成本USDT
BuyCostPrice float64 `gorm:"column:buy_cost_price;not null;default:0"`
// Quantity 策略侧同步的持仓数量(枚)
Quantity float64 `gorm:"not null;default:0"`
BuyQuantity float64 `gorm:"column:buy_quantity;not null;default:0"`
// DipAddsDone 本轮持仓已完成的超跌加仓次数(最多 4空仓时清零
DipAddsDone int `gorm:"column:dip_adds_done;not null;default:0"`
// DipLegLocked 超跌加仓波段锁
DipLegLocked bool `gorm:"column:dip_leg_locked;not null;default:false"`
// RallyLegLocked 历史字段,策略已不再使用,保留列以兼容已有库
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"`
// SellPrice 卖出价格USDT
SellPrice float64 `gorm:"column:sell_price;not null;default:0"`
// SellQuantity 卖出数量(枚)
SellQuantity float64 `gorm:"column:sell_quantity;not null;default:0"`
// 状态0-正常1-已卖出
Status int `gorm:"column:status;index;not null;default:0"`
}
func init() {