862 lines
30 KiB
Go
862 lines
30 KiB
Go
package linregmaker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"git.qtrade.icu/lychiyu/qbtrade/pkg/report"
|
|
"os"
|
|
"strconv"
|
|
"sync"
|
|
|
|
"git.qtrade.icu/lychiyu/qbtrade/pkg/risk/dynamicrisk"
|
|
|
|
"git.qtrade.icu/lychiyu/qbtrade/pkg/indicator"
|
|
"git.qtrade.icu/lychiyu/qbtrade/pkg/util"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint"
|
|
"git.qtrade.icu/lychiyu/qbtrade/pkg/qbtrade"
|
|
"git.qtrade.icu/lychiyu/qbtrade/pkg/types"
|
|
)
|
|
|
|
// TODO: Docs
|
|
|
|
const ID = "linregmaker"
|
|
|
|
var notionModifier = fixedpoint.NewFromFloat(1.1)
|
|
var two = fixedpoint.NewFromInt(2)
|
|
|
|
var log = logrus.WithField("strategy", ID)
|
|
|
|
func init() {
|
|
qbtrade.RegisterStrategy(ID, &Strategy{})
|
|
}
|
|
|
|
type Strategy struct {
|
|
// Symbol is the market symbol you want to trade
|
|
Symbol string `json:"symbol"`
|
|
|
|
// Leverage uses the account net value to calculate the allowed margin
|
|
Leverage fixedpoint.Value `json:"leverage"`
|
|
|
|
types.IntervalWindow
|
|
|
|
// ReverseEMA is used to determine the long-term trend.
|
|
// Above the ReverseEMA is the long trend and vise versa.
|
|
// All the opposite trend position will be closed upon the trend change
|
|
ReverseEMA *indicator.EWMA `json:"reverseEMA"`
|
|
|
|
// ReverseInterval is the interval to check trend reverse against ReverseEMA. Close price of this interval crossing
|
|
// the ReverseEMA triggers main trend change.
|
|
ReverseInterval types.Interval `json:"reverseInterval"`
|
|
|
|
// mainTrendCurrent is the current long-term trend
|
|
mainTrendCurrent types.Direction
|
|
// mainTrendPrevious is the long-term trend of previous kline
|
|
mainTrendPrevious types.Direction
|
|
|
|
// FastLinReg is to determine the short-term trend.
|
|
// Buy/sell orders are placed if the FastLinReg and the ReverseEMA trend are in the same direction, and only orders
|
|
// that reduce position are placed if the FastLinReg and the ReverseEMA trend are in different directions.
|
|
FastLinReg *indicator.LinReg `json:"fastLinReg"`
|
|
|
|
// SlowLinReg is to determine the midterm trend.
|
|
// When the SlowLinReg and the ReverseEMA trend are in different directions, creation of opposite position is
|
|
// allowed.
|
|
SlowLinReg *indicator.LinReg `json:"slowLinReg"`
|
|
|
|
// AllowOppositePosition if true, the creation of opposite position is allowed when both fast and slow LinReg are in
|
|
// the opposite direction to main trend
|
|
AllowOppositePosition bool `json:"allowOppositePosition"`
|
|
|
|
// FasterDecreaseRatio the quantity of decreasing position orders are multiplied by this ratio when both fast and
|
|
// slow LinReg are in the opposite direction to main trend
|
|
FasterDecreaseRatio fixedpoint.Value `json:"fasterDecreaseRatio,omitempty"`
|
|
|
|
// NeutralBollinger is the smaller range of the bollinger band
|
|
// If price is in this band, it usually means the price is oscillating.
|
|
// If price goes out of this band, we tend to not place sell orders or buy orders
|
|
NeutralBollinger types.IntervalWindowBandWidth `json:"neutralBollinger"`
|
|
|
|
// neutralBoll is the neutral price section for TradeInBand
|
|
neutralBoll *indicator.BOLL
|
|
|
|
// TradeInBand
|
|
// When this is on, places orders only when the current price is in the bollinger band.
|
|
TradeInBand bool `json:"tradeInBand"`
|
|
|
|
// Spread is the price spread from the middle price.
|
|
// For ask orders, the ask price is ((bestAsk + bestBid) / 2 * (1.0 + spread))
|
|
// For bid orders, the bid price is ((bestAsk + bestBid) / 2 * (1.0 - spread))
|
|
// Spread can be set by percentage or floating number. e.g., 0.1% or 0.001
|
|
Spread fixedpoint.Value `json:"spread"`
|
|
|
|
// BidSpread overrides the spread setting, this spread will be used for the buy order
|
|
BidSpread fixedpoint.Value `json:"bidSpread,omitempty"`
|
|
|
|
// AskSpread overrides the spread setting, this spread will be used for the sell order
|
|
AskSpread fixedpoint.Value `json:"askSpread,omitempty"`
|
|
|
|
// DynamicSpread enables the automatic adjustment to bid and ask spread.
|
|
// Overrides Spread, BidSpread, and AskSpread
|
|
DynamicSpread dynamicrisk.DynamicSpread `json:"dynamicSpread,omitempty"`
|
|
|
|
// MaxExposurePosition is the maximum position you can hold
|
|
// 10 means you can hold 10 ETH long/short position by maximum
|
|
MaxExposurePosition fixedpoint.Value `json:"maxExposurePosition"`
|
|
|
|
// DynamicExposure is used to define the exposure position range with the given percentage.
|
|
// When DynamicExposure is set, your MaxExposurePosition will be calculated dynamically
|
|
DynamicExposure dynamicrisk.DynamicExposure `json:"dynamicExposure"`
|
|
|
|
qbtrade.QuantityOrAmount
|
|
|
|
// DynamicQuantityIncrease calculates the increase position order quantity dynamically
|
|
DynamicQuantityIncrease dynamicrisk.DynamicQuantitySet `json:"dynamicQuantityIncrease"`
|
|
|
|
// DynamicQuantityDecrease calculates the decrease position order quantity dynamically
|
|
DynamicQuantityDecrease dynamicrisk.DynamicQuantitySet `json:"dynamicQuantityDecrease"`
|
|
|
|
// UseDynamicQuantityAsAmount calculates amount instead of quantity
|
|
UseDynamicQuantityAsAmount bool `json:"useDynamicQuantityAsAmount"`
|
|
|
|
// MinProfitSpread is the minimal order price spread from the current average cost.
|
|
// For long position, you will only place sell order above the price (= average cost * (1 + minProfitSpread))
|
|
// For short position, you will only place buy order below the price (= average cost * (1 - minProfitSpread))
|
|
MinProfitSpread fixedpoint.Value `json:"minProfitSpread"`
|
|
|
|
// MinProfitActivationRate activates MinProfitSpread when position RoI higher than the specified percentage
|
|
MinProfitActivationRate fixedpoint.Value `json:"minProfitActivationRate"`
|
|
|
|
// ExitMethods are various TP/SL methods
|
|
ExitMethods qbtrade.ExitMethodSet `json:"exits"`
|
|
|
|
// persistence fields
|
|
Position *types.Position `persistence:"position"`
|
|
ProfitStats *types.ProfitStats `persistence:"profit_stats"`
|
|
TradeStats *types.TradeStats `persistence:"trade_stats"`
|
|
|
|
// ProfitStatsTracker tracks profit related status and generates report
|
|
ProfitStatsTracker *report.ProfitStatsTracker `json:"profitStatsTracker"`
|
|
TrackParameters bool `json:"trackParameters"`
|
|
|
|
Environment *qbtrade.Environment
|
|
StandardIndicatorSet *qbtrade.StandardIndicatorSet
|
|
Market types.Market
|
|
ctx context.Context
|
|
|
|
session *qbtrade.ExchangeSession
|
|
|
|
orderExecutor *qbtrade.GeneralOrderExecutor
|
|
|
|
groupID uint32
|
|
|
|
// StrategyController
|
|
qbtrade.StrategyController
|
|
}
|
|
|
|
func (s *Strategy) ID() string {
|
|
return ID
|
|
}
|
|
|
|
func (s *Strategy) InstanceID() string {
|
|
return fmt.Sprintf("%s:%s", ID, s.Symbol)
|
|
}
|
|
|
|
// Validate basic config parameters
|
|
func (s *Strategy) Validate() error {
|
|
if len(s.Symbol) == 0 {
|
|
return errors.New("symbol is required")
|
|
}
|
|
|
|
if len(s.Interval) == 0 {
|
|
return errors.New("interval is required")
|
|
}
|
|
|
|
if s.ReverseEMA == nil {
|
|
return errors.New("reverseEMA must be set")
|
|
}
|
|
|
|
if s.FastLinReg == nil {
|
|
return errors.New("fastLinReg must be set")
|
|
}
|
|
|
|
if s.SlowLinReg == nil {
|
|
return errors.New("slowLinReg must be set")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Strategy) Subscribe(session *qbtrade.ExchangeSession) {
|
|
// Subscribe for ReverseEMA
|
|
session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{
|
|
Interval: s.ReverseEMA.Interval,
|
|
})
|
|
|
|
// Subscribe for ReverseInterval. Use interval of ReverseEMA if ReverseInterval is omitted
|
|
if s.ReverseInterval == "" {
|
|
s.ReverseInterval = s.ReverseEMA.Interval
|
|
}
|
|
session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{
|
|
Interval: s.ReverseInterval,
|
|
})
|
|
|
|
// Subscribe for LinRegs
|
|
session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{
|
|
Interval: s.FastLinReg.Interval,
|
|
})
|
|
session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{
|
|
Interval: s.SlowLinReg.Interval,
|
|
})
|
|
// Initialize LinRegs
|
|
kLineStore, _ := session.MarketDataStore(s.Symbol)
|
|
s.FastLinReg.BindK(session.MarketDataStream, s.Symbol, s.FastLinReg.Interval)
|
|
if klines, ok := kLineStore.KLinesOfInterval(s.FastLinReg.Interval); ok {
|
|
s.FastLinReg.LoadK((*klines)[0:])
|
|
}
|
|
s.SlowLinReg.BindK(session.MarketDataStream, s.Symbol, s.SlowLinReg.Interval)
|
|
if klines, ok := kLineStore.KLinesOfInterval(s.SlowLinReg.Interval); ok {
|
|
s.SlowLinReg.LoadK((*klines)[0:])
|
|
}
|
|
|
|
// Subscribe for BBs
|
|
if s.NeutralBollinger.Interval != "" {
|
|
session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{
|
|
Interval: s.NeutralBollinger.Interval,
|
|
})
|
|
}
|
|
|
|
// Initialize Exits
|
|
s.ExitMethods.SetAndSubscribe(session, s)
|
|
|
|
// Initialize dynamic spread
|
|
if s.DynamicSpread.IsEnabled() {
|
|
s.DynamicSpread.Initialize(s.Symbol, session)
|
|
}
|
|
|
|
// Initialize dynamic exposure
|
|
if s.DynamicExposure.IsEnabled() {
|
|
s.DynamicExposure.Initialize(s.Symbol, session)
|
|
}
|
|
|
|
// Initialize dynamic quantities
|
|
if len(s.DynamicQuantityIncrease) > 0 {
|
|
s.DynamicQuantityIncrease.Initialize(s.Symbol, session)
|
|
}
|
|
if len(s.DynamicQuantityDecrease) > 0 {
|
|
s.DynamicQuantityDecrease.Initialize(s.Symbol, session)
|
|
}
|
|
|
|
// Profit tracker
|
|
if s.ProfitStatsTracker != nil {
|
|
s.ProfitStatsTracker.Subscribe(session, s.Symbol)
|
|
}
|
|
}
|
|
|
|
func (s *Strategy) CurrentPosition() *types.Position {
|
|
return s.Position
|
|
}
|
|
|
|
func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error {
|
|
return s.orderExecutor.ClosePosition(ctx, percentage)
|
|
}
|
|
|
|
// isAllowOppositePosition returns if opening opposite position is allowed
|
|
func (s *Strategy) isAllowOppositePosition() bool {
|
|
if !s.AllowOppositePosition {
|
|
return false
|
|
}
|
|
|
|
if (s.mainTrendCurrent == types.DirectionUp && s.FastLinReg.Last(0) < 0 && s.SlowLinReg.Last(0) < 0) ||
|
|
(s.mainTrendCurrent == types.DirectionDown && s.FastLinReg.Last(0) > 0 && s.SlowLinReg.Last(0) > 0) {
|
|
log.Infof("%s allow opposite position is enabled: MainTrend %v, FastLinReg: %f, SlowLinReg: %f", s.Symbol, s.mainTrendCurrent, s.FastLinReg.Last(0), s.SlowLinReg.Last(0))
|
|
return true
|
|
}
|
|
log.Infof("%s allow opposite position is disabled: MainTrend %v, FastLinReg: %f, SlowLinReg: %f", s.Symbol, s.mainTrendCurrent, s.FastLinReg.Last(0), s.SlowLinReg.Last(0))
|
|
|
|
return false
|
|
}
|
|
|
|
// updateSpread for ask and bid price
|
|
func (s *Strategy) updateSpread() {
|
|
// Update spreads with dynamic spread
|
|
if s.DynamicSpread.IsEnabled() {
|
|
dynamicBidSpread, err := s.DynamicSpread.GetBidSpread()
|
|
if err == nil && dynamicBidSpread > 0 {
|
|
s.BidSpread = fixedpoint.NewFromFloat(dynamicBidSpread)
|
|
log.Infof("%s dynamic bid spread updated: %s", s.Symbol, s.BidSpread.Percentage())
|
|
}
|
|
dynamicAskSpread, err := s.DynamicSpread.GetAskSpread()
|
|
if err == nil && dynamicAskSpread > 0 {
|
|
s.AskSpread = fixedpoint.NewFromFloat(dynamicAskSpread)
|
|
log.Infof("%s dynamic ask spread updated: %s", s.Symbol, s.AskSpread.Percentage())
|
|
}
|
|
}
|
|
|
|
if s.BidSpread.Sign() <= 0 {
|
|
s.BidSpread = s.Spread
|
|
}
|
|
|
|
if s.BidSpread.Sign() <= 0 {
|
|
s.AskSpread = s.Spread
|
|
}
|
|
}
|
|
|
|
// updateMaxExposure with dynamic exposure
|
|
func (s *Strategy) updateMaxExposure(midPrice fixedpoint.Value) {
|
|
// Calculate max exposure
|
|
if s.DynamicExposure.IsEnabled() {
|
|
var err error
|
|
maxExposurePosition, err := s.DynamicExposure.GetMaxExposure(midPrice.Float64(), s.mainTrendCurrent)
|
|
if err != nil {
|
|
log.WithError(err).Errorf("can not calculate DynamicExposure of %s, use previous MaxExposurePosition instead", s.Symbol)
|
|
qbtrade.Notify("can not calculate DynamicExposure of %s, use previous MaxExposurePosition instead", s.Symbol)
|
|
} else {
|
|
s.MaxExposurePosition = maxExposurePosition
|
|
}
|
|
log.Infof("calculated %s max exposure position: %v", s.Symbol, s.MaxExposurePosition)
|
|
}
|
|
}
|
|
|
|
// getOrderPrices returns ask and bid prices
|
|
func (s *Strategy) getOrderPrices(midPrice fixedpoint.Value) (askPrice fixedpoint.Value, bidPrice fixedpoint.Value) {
|
|
askPrice = midPrice.Mul(fixedpoint.One.Add(s.AskSpread))
|
|
bidPrice = midPrice.Mul(fixedpoint.One.Sub(s.BidSpread))
|
|
log.Infof("%s mid price:%v ask:%v bid: %v", s.Symbol, midPrice, askPrice, bidPrice)
|
|
|
|
return askPrice, bidPrice
|
|
}
|
|
|
|
// adjustQuantity to meet the min notional and qty requirement
|
|
func (s *Strategy) adjustQuantity(quantity, price fixedpoint.Value) fixedpoint.Value {
|
|
adjustedQty := quantity
|
|
if quantity.Mul(price).Compare(s.Market.MinNotional) < 0 {
|
|
adjustedQty = qbtrade.AdjustFloatQuantityByMinAmount(quantity, price, s.Market.MinNotional.Mul(notionModifier))
|
|
}
|
|
|
|
if adjustedQty.Compare(s.Market.MinQuantity) < 0 {
|
|
adjustedQty = fixedpoint.Max(adjustedQty, s.Market.MinQuantity)
|
|
}
|
|
|
|
return adjustedQty
|
|
}
|
|
|
|
// getOrderQuantities returns sell and buy qty
|
|
func (s *Strategy) getOrderQuantities(askPrice fixedpoint.Value, bidPrice fixedpoint.Value) (sellQuantity fixedpoint.Value, buyQuantity fixedpoint.Value) {
|
|
// Default
|
|
sellQuantity = s.QuantityOrAmount.CalculateQuantity(askPrice)
|
|
buyQuantity = s.QuantityOrAmount.CalculateQuantity(bidPrice)
|
|
|
|
// Dynamic qty
|
|
switch {
|
|
case s.mainTrendCurrent == types.DirectionUp:
|
|
if len(s.DynamicQuantityIncrease) > 0 {
|
|
qty, err := s.DynamicQuantityIncrease.GetQuantity(false)
|
|
if err == nil {
|
|
buyQuantity = qty
|
|
} else {
|
|
log.WithError(err).Errorf("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol)
|
|
qbtrade.Notify("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol)
|
|
}
|
|
}
|
|
if len(s.DynamicQuantityDecrease) > 0 {
|
|
qty, err := s.DynamicQuantityDecrease.GetQuantity(false)
|
|
if err == nil {
|
|
sellQuantity = qty
|
|
} else {
|
|
log.WithError(err).Errorf("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol)
|
|
qbtrade.Notify("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol)
|
|
}
|
|
}
|
|
case s.mainTrendCurrent == types.DirectionDown:
|
|
if len(s.DynamicQuantityIncrease) > 0 {
|
|
qty, err := s.DynamicQuantityIncrease.GetQuantity(true)
|
|
if err == nil {
|
|
sellQuantity = qty
|
|
} else {
|
|
log.WithError(err).Errorf("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol)
|
|
qbtrade.Notify("cannot get dynamic sell qty of %s, use default qty instead", s.Symbol)
|
|
}
|
|
}
|
|
if len(s.DynamicQuantityDecrease) > 0 {
|
|
qty, err := s.DynamicQuantityDecrease.GetQuantity(true)
|
|
if err == nil {
|
|
buyQuantity = qty
|
|
} else {
|
|
log.WithError(err).Errorf("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol)
|
|
qbtrade.Notify("cannot get dynamic buy qty of %s, use default qty instead", s.Symbol)
|
|
}
|
|
}
|
|
}
|
|
if s.UseDynamicQuantityAsAmount {
|
|
log.Infof("caculated %s buy amount %v, sell amount %v", s.Symbol, buyQuantity, sellQuantity)
|
|
qtyAmount := qbtrade.QuantityOrAmount{Amount: buyQuantity}
|
|
buyQuantity = qtyAmount.CalculateQuantity(bidPrice)
|
|
qtyAmount.Amount = sellQuantity
|
|
sellQuantity = qtyAmount.CalculateQuantity(askPrice)
|
|
log.Infof("convert %s amount to buy qty %v, sell qty %v", s.Symbol, buyQuantity, sellQuantity)
|
|
} else {
|
|
log.Infof("caculated %s buy qty %v, sell qty %v", s.Symbol, buyQuantity, sellQuantity)
|
|
}
|
|
|
|
// Faster position decrease
|
|
if s.mainTrendCurrent == types.DirectionUp && s.SlowLinReg.Last(0) < 0 {
|
|
sellQuantity = sellQuantity.Mul(s.FasterDecreaseRatio)
|
|
log.Infof("faster %s position decrease: sell qty %v", s.Symbol, sellQuantity)
|
|
} else if s.mainTrendCurrent == types.DirectionDown && s.SlowLinReg.Last(0) > 0 {
|
|
buyQuantity = buyQuantity.Mul(s.FasterDecreaseRatio)
|
|
log.Infof("faster %s position decrease: buy qty %v", s.Symbol, buyQuantity)
|
|
}
|
|
|
|
// Reduce order qty to fit current position
|
|
if !s.isAllowOppositePosition() {
|
|
if s.Position.IsLong() && s.Position.Base.Abs().Compare(sellQuantity) < 0 {
|
|
sellQuantity = s.Position.Base.Abs()
|
|
} else if s.Position.IsShort() && s.Position.Base.Abs().Compare(buyQuantity) < 0 {
|
|
buyQuantity = s.Position.Base.Abs()
|
|
}
|
|
}
|
|
|
|
if buyQuantity.Compare(fixedpoint.Zero) > 0 {
|
|
buyQuantity = s.adjustQuantity(buyQuantity, bidPrice)
|
|
}
|
|
if sellQuantity.Compare(fixedpoint.Zero) > 0 {
|
|
sellQuantity = s.adjustQuantity(sellQuantity, askPrice)
|
|
}
|
|
|
|
log.Infof("adjusted sell qty:%v buy qty: %v", sellQuantity, buyQuantity)
|
|
|
|
return sellQuantity, buyQuantity
|
|
}
|
|
|
|
// getAllowedBalance returns the allowed qty of orders
|
|
func (s *Strategy) getAllowedBalance() (baseQty, quoteQty fixedpoint.Value) {
|
|
// Default
|
|
baseQty = fixedpoint.PosInf
|
|
quoteQty = fixedpoint.PosInf
|
|
|
|
balances := s.session.GetAccount().Balances()
|
|
baseBalance, hasBaseBalance := balances[s.Market.BaseCurrency]
|
|
quoteBalance, hasQuoteBalance := balances[s.Market.QuoteCurrency]
|
|
lastPrice, _ := s.session.LastPrice(s.Symbol)
|
|
|
|
if qbtrade.IsBackTesting { // Backtesting
|
|
baseQty = s.Position.Base
|
|
quoteQty = quoteBalance.Available.Sub(fixedpoint.Max(s.Position.Quote.Mul(fixedpoint.Two), fixedpoint.Zero))
|
|
} else if s.session.Margin || s.session.IsolatedMargin || s.session.Futures || s.session.IsolatedFutures { // Leveraged
|
|
quoteQ, err := qbtrade.CalculateQuoteQuantity(s.ctx, s.session, s.Market.QuoteCurrency, s.Leverage)
|
|
if err != nil {
|
|
quoteQ = fixedpoint.Zero
|
|
}
|
|
quoteQty = quoteQ
|
|
baseQty = quoteQ.Div(lastPrice)
|
|
} else { // Spot
|
|
if !hasBaseBalance {
|
|
baseQty = fixedpoint.Zero
|
|
} else {
|
|
baseQty = baseBalance.Available
|
|
}
|
|
if !hasQuoteBalance {
|
|
quoteQty = fixedpoint.Zero
|
|
} else {
|
|
quoteQty = quoteBalance.Available
|
|
}
|
|
}
|
|
|
|
return baseQty, quoteQty
|
|
}
|
|
|
|
// getCanBuySell returns the buy sell switches
|
|
func (s *Strategy) getCanBuySell(buyQuantity, bidPrice, sellQuantity, askPrice, midPrice fixedpoint.Value) (canBuy bool, canSell bool) {
|
|
// By default, both buy and sell are on, which means we will place buy and sell orders
|
|
canBuy = true
|
|
canSell = true
|
|
|
|
// Check if current position > maxExposurePosition
|
|
if s.Position.GetBase().Abs().Compare(s.MaxExposurePosition) > 0 {
|
|
if s.Position.IsLong() {
|
|
canBuy = false
|
|
} else if s.Position.IsShort() {
|
|
canSell = false
|
|
}
|
|
log.Infof("current position %v larger than max exposure %v, skip increase position", s.Position.GetBase().Abs(), s.MaxExposurePosition)
|
|
}
|
|
|
|
// Check TradeInBand
|
|
if s.TradeInBand {
|
|
// Price too high
|
|
if bidPrice.Float64() > s.neutralBoll.UpBand.Last(0) {
|
|
canBuy = false
|
|
log.Infof("tradeInBand is set, skip buy due to the price is higher than the neutralBB")
|
|
}
|
|
// Price too low in uptrend
|
|
if askPrice.Float64() < s.neutralBoll.DownBand.Last(0) {
|
|
canSell = false
|
|
log.Infof("tradeInBand is set, skip sell due to the price is lower than the neutralBB")
|
|
}
|
|
}
|
|
|
|
// Stop decrease when position closed unless both LinRegs are in the opposite direction to the main trend
|
|
if !s.isAllowOppositePosition() {
|
|
if s.mainTrendCurrent == types.DirectionUp && (s.Position.IsClosed() || s.Position.IsDust(askPrice)) {
|
|
canSell = false
|
|
log.Infof("skip sell due to the long position is closed")
|
|
} else if s.mainTrendCurrent == types.DirectionDown && (s.Position.IsClosed() || s.Position.IsDust(bidPrice)) {
|
|
canBuy = false
|
|
log.Infof("skip buy due to the short position is closed")
|
|
}
|
|
}
|
|
|
|
// Min profit
|
|
roi := s.Position.ROI(midPrice)
|
|
if roi.Compare(s.MinProfitActivationRate) >= 0 {
|
|
if s.Position.IsLong() && !s.Position.IsDust(askPrice) {
|
|
minProfitPrice := s.Position.AverageCost.Mul(fixedpoint.One.Add(s.MinProfitSpread))
|
|
if askPrice.Compare(minProfitPrice) < 0 {
|
|
canSell = false
|
|
log.Infof("askPrice %v is less than minProfitPrice %v. skip sell", askPrice, minProfitPrice)
|
|
}
|
|
} else if s.Position.IsShort() && s.Position.IsDust(bidPrice) {
|
|
minProfitPrice := s.Position.AverageCost.Mul(fixedpoint.One.Sub(s.MinProfitSpread))
|
|
if bidPrice.Compare(minProfitPrice) > 0 {
|
|
canBuy = false
|
|
log.Infof("bidPrice %v is greater than minProfitPrice %v. skip buy", bidPrice, minProfitPrice)
|
|
}
|
|
}
|
|
} else {
|
|
log.Infof("position RoI %v is less than minProfitActivationRate %v. min profit protection is not active", roi, s.MinProfitActivationRate)
|
|
}
|
|
|
|
// Check against account balance
|
|
baseQty, quoteQty := s.getAllowedBalance()
|
|
if s.session.Margin || s.session.IsolatedMargin || s.session.Futures || s.session.IsolatedFutures { // Leveraged
|
|
if quoteQty.Compare(fixedpoint.Zero) <= 0 {
|
|
if s.Position.IsLong() {
|
|
canBuy = false
|
|
log.Infof("skip buy due to the account has no available balance")
|
|
} else if s.Position.IsShort() {
|
|
canSell = false
|
|
log.Infof("skip sell due to the account has no available balance")
|
|
}
|
|
}
|
|
} else {
|
|
if buyQuantity.Compare(quoteQty.Div(bidPrice)) > 0 { // Spot
|
|
canBuy = false
|
|
log.Infof("skip buy due to the account has no available balance")
|
|
}
|
|
if sellQuantity.Compare(baseQty) > 0 {
|
|
canSell = false
|
|
log.Infof("skip sell due to the account has no available balance")
|
|
}
|
|
}
|
|
|
|
log.Infof("canBuy %t, canSell %t", canBuy, canSell)
|
|
return canBuy, canSell
|
|
}
|
|
|
|
// getOrderForms returns buy and sell order form for submission
|
|
func (s *Strategy) getOrderForms(buyQuantity, bidPrice, sellQuantity, askPrice fixedpoint.Value) (buyOrder types.SubmitOrder, sellOrder types.SubmitOrder) {
|
|
sellOrder = types.SubmitOrder{
|
|
Symbol: s.Symbol,
|
|
Side: types.SideTypeSell,
|
|
Type: types.OrderTypeLimitMaker,
|
|
Quantity: sellQuantity,
|
|
Price: askPrice,
|
|
Market: s.Market,
|
|
GroupID: s.groupID,
|
|
}
|
|
buyOrder = types.SubmitOrder{
|
|
Symbol: s.Symbol,
|
|
Side: types.SideTypeBuy,
|
|
Type: types.OrderTypeLimitMaker,
|
|
Quantity: buyQuantity,
|
|
Price: bidPrice,
|
|
Market: s.Market,
|
|
GroupID: s.groupID,
|
|
}
|
|
|
|
isMargin := s.session.Margin || s.session.IsolatedMargin
|
|
isFutures := s.session.Futures || s.session.IsolatedFutures
|
|
|
|
if s.Position.IsClosed() {
|
|
if isMargin {
|
|
buyOrder.MarginSideEffect = types.SideEffectTypeMarginBuy
|
|
sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy
|
|
} else if isFutures {
|
|
buyOrder.ReduceOnly = false
|
|
sellOrder.ReduceOnly = false
|
|
}
|
|
} else if s.Position.IsLong() {
|
|
if isMargin {
|
|
buyOrder.MarginSideEffect = types.SideEffectTypeMarginBuy
|
|
sellOrder.MarginSideEffect = types.SideEffectTypeAutoRepay
|
|
} else if isFutures {
|
|
buyOrder.ReduceOnly = false
|
|
sellOrder.ReduceOnly = true
|
|
}
|
|
|
|
if s.Position.Base.Abs().Compare(sellOrder.Quantity) < 0 {
|
|
if isMargin {
|
|
sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy
|
|
} else if isFutures {
|
|
sellOrder.ReduceOnly = false
|
|
}
|
|
}
|
|
} else if s.Position.IsShort() {
|
|
if isMargin {
|
|
buyOrder.MarginSideEffect = types.SideEffectTypeAutoRepay
|
|
sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy
|
|
} else if isFutures {
|
|
buyOrder.ReduceOnly = true
|
|
sellOrder.ReduceOnly = false
|
|
}
|
|
|
|
if s.Position.Base.Abs().Compare(buyOrder.Quantity) < 0 {
|
|
if isMargin {
|
|
sellOrder.MarginSideEffect = types.SideEffectTypeMarginBuy
|
|
} else if isFutures {
|
|
sellOrder.ReduceOnly = false
|
|
}
|
|
}
|
|
}
|
|
|
|
return buyOrder, sellOrder
|
|
}
|
|
|
|
func (s *Strategy) Run(ctx context.Context, orderExecutor qbtrade.OrderExecutor, session *qbtrade.ExchangeSession) error {
|
|
log.Debugf("%v", orderExecutor) // Here just to suppress GoLand warning
|
|
// initial required information
|
|
s.session = session
|
|
s.ctx = ctx
|
|
|
|
// Calculate group id for orders
|
|
instanceID := s.InstanceID()
|
|
s.groupID = util.FNV32(instanceID)
|
|
|
|
// If position is nil, we need to allocate a new position for calculation
|
|
if s.Position == nil {
|
|
s.Position = types.NewPositionFromMarket(s.Market)
|
|
}
|
|
|
|
// Set fee rate
|
|
if s.session.MakerFeeRate.Sign() > 0 || s.session.TakerFeeRate.Sign() > 0 {
|
|
s.Position.SetExchangeFeeRate(s.session.ExchangeName, types.ExchangeFee{
|
|
MakerFeeRate: s.session.MakerFeeRate,
|
|
TakerFeeRate: s.session.TakerFeeRate,
|
|
})
|
|
}
|
|
|
|
// If position is nil, we need to allocate a new position for calculation
|
|
if s.Position == nil {
|
|
s.Position = types.NewPositionFromMarket(s.Market)
|
|
}
|
|
// Always update the position fields
|
|
s.Position.Strategy = ID
|
|
s.Position.StrategyInstanceID = s.InstanceID()
|
|
|
|
// Profit stats
|
|
if s.ProfitStats == nil {
|
|
s.ProfitStats = types.NewProfitStats(s.Market)
|
|
}
|
|
|
|
if s.TradeStats == nil {
|
|
s.TradeStats = types.NewTradeStats(s.Symbol)
|
|
}
|
|
|
|
s.orderExecutor = qbtrade.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position)
|
|
s.orderExecutor.BindEnvironment(s.Environment)
|
|
s.orderExecutor.BindProfitStats(s.ProfitStats)
|
|
s.orderExecutor.BindTradeStats(s.TradeStats)
|
|
s.orderExecutor.Bind()
|
|
s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) {
|
|
qbtrade.Sync(ctx, s)
|
|
})
|
|
s.ExitMethods.Bind(session, s.orderExecutor)
|
|
|
|
// Setup profit tracker
|
|
if s.ProfitStatsTracker != nil {
|
|
if s.ProfitStatsTracker.CurrentProfitStats == nil {
|
|
s.ProfitStatsTracker.InitLegacy(s.Market, &s.ProfitStats, s.TradeStats)
|
|
}
|
|
|
|
// Add strategy parameters to report
|
|
if s.TrackParameters && s.ProfitStatsTracker.AccumulatedProfitReport != nil {
|
|
s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("ReverseEMAWindow", strconv.Itoa(s.ReverseEMA.Window))
|
|
s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("FastLinRegWindow", strconv.Itoa(s.FastLinReg.Window))
|
|
s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("FastLinRegInterval", s.FastLinReg.Interval.String())
|
|
s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("SlowLinRegWindow", strconv.Itoa(s.SlowLinReg.Window))
|
|
s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("SlowLinRegInterval", s.SlowLinReg.Interval.String())
|
|
s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("FasterDecreaseRatio", strconv.FormatFloat(s.FasterDecreaseRatio.Float64(), 'f', 4, 64))
|
|
s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("NeutralBollingerWindow", strconv.Itoa(s.NeutralBollinger.Window))
|
|
s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("NeutralBollingerBandWidth", strconv.FormatFloat(s.NeutralBollinger.BandWidth, 'f', 4, 64))
|
|
s.ProfitStatsTracker.AccumulatedProfitReport.AddStrategyParameter("Spread", strconv.FormatFloat(s.Spread.Float64(), 'f', 4, 64))
|
|
}
|
|
|
|
s.ProfitStatsTracker.Bind(s.session, s.orderExecutor.TradeCollector())
|
|
}
|
|
|
|
// Indicators initialized by StandardIndicatorSet must be initialized in Run()
|
|
// Initialize ReverseEMA
|
|
s.ReverseEMA = s.StandardIndicatorSet.EWMA(s.ReverseEMA.IntervalWindow)
|
|
// Initialize BBs
|
|
s.neutralBoll = s.StandardIndicatorSet.BOLL(s.NeutralBollinger.IntervalWindow, s.NeutralBollinger.BandWidth)
|
|
|
|
// Default spread
|
|
if s.Spread == fixedpoint.Zero {
|
|
s.Spread = fixedpoint.NewFromFloat(0.001)
|
|
}
|
|
|
|
// StrategyController
|
|
s.Status = types.StrategyStatusRunning
|
|
s.OnSuspend(func() {
|
|
_ = s.orderExecutor.GracefulCancel(ctx)
|
|
qbtrade.Sync(ctx, s)
|
|
})
|
|
s.OnEmergencyStop(func() {
|
|
// Close whole position
|
|
_ = s.ClosePosition(ctx, fixedpoint.NewFromFloat(1.0))
|
|
})
|
|
|
|
// Initial trend
|
|
session.UserDataStream.OnStart(func() {
|
|
var closePrice fixedpoint.Value
|
|
if !qbtrade.IsBackTesting {
|
|
ticker, err := s.session.Exchange.QueryTicker(ctx, s.Symbol)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
closePrice = ticker.Buy.Add(ticker.Sell).Div(two)
|
|
} else {
|
|
if price, ok := session.LastPrice(s.Symbol); ok {
|
|
closePrice = price
|
|
}
|
|
}
|
|
priceReverseEMA := fixedpoint.NewFromFloat(s.ReverseEMA.Last(0))
|
|
|
|
// Main trend by ReverseEMA
|
|
if closePrice.Compare(priceReverseEMA) > 0 {
|
|
s.mainTrendCurrent = types.DirectionUp
|
|
} else if closePrice.Compare(priceReverseEMA) < 0 {
|
|
s.mainTrendCurrent = types.DirectionDown
|
|
}
|
|
})
|
|
|
|
// Check trend reversal
|
|
session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.ReverseInterval, func(kline types.KLine) {
|
|
// closePrice is the close price of current kline
|
|
closePrice := kline.GetClose()
|
|
// priceReverseEMA is the current ReverseEMA price
|
|
priceReverseEMA := fixedpoint.NewFromFloat(s.ReverseEMA.Last(0))
|
|
|
|
// Main trend by ReverseEMA
|
|
s.mainTrendPrevious = s.mainTrendCurrent
|
|
if closePrice.Compare(priceReverseEMA) > 0 {
|
|
s.mainTrendCurrent = types.DirectionUp
|
|
} else if closePrice.Compare(priceReverseEMA) < 0 {
|
|
s.mainTrendCurrent = types.DirectionDown
|
|
}
|
|
log.Infof("%s current trend is %v", s.Symbol, s.mainTrendCurrent)
|
|
|
|
// Trend reversal
|
|
if s.mainTrendCurrent != s.mainTrendPrevious {
|
|
log.Infof("%s trend reverse to %v", s.Symbol, s.mainTrendCurrent)
|
|
qbtrade.Notify("%s trend reverse to %v", s.Symbol, s.mainTrendCurrent)
|
|
// Close on-hand position that is not in the same direction as the new trend
|
|
if !s.Position.IsDust(closePrice) &&
|
|
((s.Position.IsLong() && s.mainTrendCurrent == types.DirectionDown) ||
|
|
(s.Position.IsShort() && s.mainTrendCurrent == types.DirectionUp)) {
|
|
log.Infof("%s closing on-hand position due to trend reverse", s.Symbol)
|
|
qbtrade.Notify("%s closing on-hand position due to trend reverse", s.Symbol)
|
|
if err := s.ClosePosition(ctx, fixedpoint.One); err != nil {
|
|
log.WithError(err).Errorf("cannot close on-hand position of %s", s.Symbol)
|
|
qbtrade.Notify("cannot close on-hand position of %s", s.Symbol)
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
|
|
// Main interval
|
|
session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) {
|
|
// StrategyController
|
|
if s.Status != types.StrategyStatusRunning {
|
|
return
|
|
}
|
|
|
|
_ = s.orderExecutor.GracefulCancel(ctx)
|
|
|
|
// closePrice is the close price of current kline
|
|
closePrice := kline.GetClose()
|
|
|
|
// midPrice for ask and bid prices
|
|
var midPrice fixedpoint.Value
|
|
if !qbtrade.IsBackTesting {
|
|
ticker, err := s.session.Exchange.QueryTicker(ctx, s.Symbol)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
midPrice = ticker.Buy.Add(ticker.Sell).Div(two)
|
|
log.Infof("using ticker price: bid %v / ask %v, mid price %v", ticker.Buy, ticker.Sell, midPrice)
|
|
} else {
|
|
midPrice = closePrice
|
|
}
|
|
|
|
// Update price spread
|
|
s.updateSpread()
|
|
|
|
// Update max exposure
|
|
s.updateMaxExposure(midPrice)
|
|
|
|
// Current position status
|
|
log.Infof("position: %s", s.Position)
|
|
if !s.Position.IsClosed() && !s.Position.IsDust(midPrice) {
|
|
log.Infof("current %s unrealized profit: %f %s", s.Symbol, s.Position.UnrealizedProfit(midPrice).Float64(), s.Market.QuoteCurrency)
|
|
}
|
|
|
|
// Order prices
|
|
askPrice, bidPrice := s.getOrderPrices(midPrice)
|
|
|
|
// Order qty
|
|
sellQuantity, buyQuantity := s.getOrderQuantities(askPrice, bidPrice)
|
|
|
|
buyOrder, sellOrder := s.getOrderForms(buyQuantity, bidPrice, sellQuantity, askPrice)
|
|
|
|
canBuy, canSell := s.getCanBuySell(buyQuantity, bidPrice, sellQuantity, askPrice, midPrice)
|
|
|
|
// Submit orders
|
|
var submitOrders []types.SubmitOrder
|
|
if canSell && sellOrder.Quantity.Compare(fixedpoint.Zero) > 0 {
|
|
submitOrders = append(submitOrders, sellOrder)
|
|
}
|
|
if canBuy && buyOrder.Quantity.Compare(fixedpoint.Zero) > 0 {
|
|
submitOrders = append(submitOrders, buyOrder)
|
|
}
|
|
|
|
if len(submitOrders) == 0 {
|
|
return
|
|
}
|
|
log.Infof("submitting order(s): %v", submitOrders)
|
|
_, _ = s.orderExecutor.SubmitOrders(ctx, submitOrders...)
|
|
}))
|
|
|
|
qbtrade.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) {
|
|
defer wg.Done()
|
|
|
|
// Output profit report
|
|
if s.ProfitStatsTracker != nil {
|
|
if s.ProfitStatsTracker.AccumulatedProfitReport != nil {
|
|
s.ProfitStatsTracker.AccumulatedProfitReport.Output()
|
|
}
|
|
}
|
|
|
|
_ = s.orderExecutor.GracefulCancel(ctx)
|
|
_, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String())
|
|
})
|
|
|
|
return nil
|
|
}
|