package types import ( "fmt" "sync" "time" "github.com/slack-go/slack" "git.qtrade.icu/lychiyu/qbtrade/pkg/fixedpoint" "git.qtrade.icu/lychiyu/qbtrade/pkg/util/templateutil" ) type PositionType string const ( PositionShort = PositionType("Short") PositionLong = PositionType("Long") PositionClosed = PositionType("Closed") ) type ExchangeFee struct { MakerFeeRate fixedpoint.Value TakerFeeRate fixedpoint.Value } type PositionRisk struct { Leverage fixedpoint.Value `json:"leverage"` LiquidationPrice fixedpoint.Value `json:"liquidationPrice"` } type Position struct { Symbol string `json:"symbol" db:"symbol"` BaseCurrency string `json:"baseCurrency" db:"base"` QuoteCurrency string `json:"quoteCurrency" db:"quote"` Market Market `json:"market,omitempty"` Base fixedpoint.Value `json:"base" db:"base"` Quote fixedpoint.Value `json:"quote" db:"quote"` AverageCost fixedpoint.Value `json:"averageCost" db:"average_cost"` // ApproximateAverageCost adds the computed fee in quote in the average cost // This is used for calculating net profit ApproximateAverageCost fixedpoint.Value `json:"approximateAverageCost"` FeeRate *ExchangeFee `json:"feeRate,omitempty"` ExchangeFeeRates map[ExchangeName]ExchangeFee `json:"exchangeFeeRates"` // TotalFee stores the fee currency -> total fee quantity TotalFee map[string]fixedpoint.Value `json:"totalFee" db:"-"` OpenedAt time.Time `json:"openedAt,omitempty" db:"-"` ChangedAt time.Time `json:"changedAt,omitempty" db:"changed_at"` Strategy string `json:"strategy,omitempty" db:"strategy"` StrategyInstanceID string `json:"strategyInstanceID,omitempty" db:"strategy_instance_id"` AccumulatedProfit fixedpoint.Value `json:"accumulatedProfit,omitempty" db:"accumulated_profit"` // closing is a flag for marking this position is closing closing bool sync.Mutex // Modify position callbacks modifyCallbacks []func(baseQty fixedpoint.Value, quoteQty fixedpoint.Value, price fixedpoint.Value) // ttl is the ttl to keep in persistence ttl time.Duration } func (s *Position) SetTTL(ttl time.Duration) { if ttl.Nanoseconds() <= 0 { return } s.ttl = ttl } func (s *Position) Expiration() time.Duration { return s.ttl } func (p *Position) CsvHeader() []string { return []string{ "symbol", "time", "average_cost", "base", "quote", "accumulated_profit", } } func (p *Position) CsvRecords() [][]string { if p.AverageCost.IsZero() && p.Base.IsZero() { return nil } return [][]string{ { p.Symbol, p.ChangedAt.UTC().Format(time.RFC1123), p.AverageCost.String(), p.Base.String(), p.Quote.String(), p.AccumulatedProfit.String(), }, } } // NewProfit generates the profit object from the current position func (p *Position) NewProfit(trade Trade, profit, netProfit fixedpoint.Value) Profit { return Profit{ Symbol: p.Symbol, QuoteCurrency: p.QuoteCurrency, BaseCurrency: p.BaseCurrency, AverageCost: p.AverageCost, // profit related fields Profit: profit, NetProfit: netProfit, ProfitMargin: profit.Div(trade.QuoteQuantity), NetProfitMargin: netProfit.Div(trade.QuoteQuantity), // trade related fields Trade: &trade, TradeID: trade.ID, OrderID: trade.OrderID, Side: trade.Side, IsBuyer: trade.IsBuyer, IsMaker: trade.IsMaker, Price: trade.Price, Quantity: trade.Quantity, QuoteQuantity: trade.QuoteQuantity, // FeeInUSD: 0, Fee: trade.Fee, FeeCurrency: trade.FeeCurrency, Exchange: trade.Exchange, IsMargin: trade.IsMargin, IsFutures: trade.IsFutures, IsIsolated: trade.IsIsolated, TradedAt: trade.Time.Time(), Strategy: p.Strategy, StrategyInstanceID: p.StrategyInstanceID, PositionOpenedAt: p.OpenedAt, } } // ROI -- Return on investment (ROI) is a performance measure used to evaluate the efficiency or profitability of an investment // or compare the efficiency of a number of different investments. // ROI tries to directly measure the amount of return on a particular investment, relative to the investment's cost. func (p *Position) ROI(price fixedpoint.Value) fixedpoint.Value { unrealizedProfit := p.UnrealizedProfit(price) cost := p.AverageCost.Mul(p.Base.Abs()) return unrealizedProfit.Div(cost) } func (p *Position) NewMarketCloseOrder(percentage fixedpoint.Value) *SubmitOrder { base := p.GetBase() quantity := base.Abs() if percentage.Compare(fixedpoint.One) < 0 { quantity = quantity.Mul(percentage) } if quantity.Compare(p.Market.MinQuantity) < 0 { return nil } side := SideTypeSell sign := base.Sign() if sign == 0 { return nil } else if sign < 0 { side = SideTypeBuy } return &SubmitOrder{ Symbol: p.Symbol, Market: p.Market, Type: OrderTypeMarket, Side: side, Quantity: quantity, MarginSideEffect: SideEffectTypeAutoRepay, } } func (p *Position) IsDust(a ...fixedpoint.Value) bool { price := p.AverageCost if len(a) > 0 { price = a[0] } base := p.Base.Abs() return p.Market.IsDustQuantity(base, price) } // GetBase locks the mutex and return the base quantity // The base quantity can be negative func (p *Position) GetBase() (base fixedpoint.Value) { p.Lock() base = p.Base p.Unlock() return base } // GetQuantity calls GetBase() and then convert the number into a positive number // that could be treated as a quantity. func (p *Position) GetQuantity() fixedpoint.Value { base := p.GetBase() return base.Abs() } func (p *Position) UnrealizedProfit(price fixedpoint.Value) fixedpoint.Value { quantity := p.GetBase().Abs() if p.IsLong() { return price.Sub(p.AverageCost).Mul(quantity) } else if p.IsShort() { return p.AverageCost.Sub(price).Mul(quantity) } return fixedpoint.Zero } func (p *Position) OnModify(cb func(baseQty fixedpoint.Value, quoteQty fixedpoint.Value, price fixedpoint.Value)) { p.modifyCallbacks = append(p.modifyCallbacks, cb) } func (p *Position) EmitModify(baseQty fixedpoint.Value, quoteQty fixedpoint.Value, price fixedpoint.Value) { for _, cb := range p.modifyCallbacks { cb(baseQty, quoteQty, price) } } // ModifyBase modifies position base quantity with `qty` func (p *Position) ModifyBase(qty fixedpoint.Value) error { p.Base = qty p.EmitModify(p.Base, p.Quote, p.AverageCost) return nil } // ModifyQuote modifies position quote quantity with `qty` func (p *Position) ModifyQuote(qty fixedpoint.Value) error { p.Quote = qty p.EmitModify(p.Base, p.Quote, p.AverageCost) return nil } // ModifyAverageCost modifies position average cost with `price` func (p *Position) ModifyAverageCost(price fixedpoint.Value) error { p.AverageCost = price p.EmitModify(p.Base, p.Quote, p.AverageCost) return nil } type FuturesPosition struct { Symbol string `json:"symbol"` BaseCurrency string `json:"baseCurrency"` QuoteCurrency string `json:"quoteCurrency"` Market Market `json:"market"` Base fixedpoint.Value `json:"base"` Quote fixedpoint.Value `json:"quote"` AverageCost fixedpoint.Value `json:"averageCost"` // ApproximateAverageCost adds the computed fee in quote in the average cost // This is used for calculating net profit ApproximateAverageCost fixedpoint.Value `json:"approximateAverageCost"` FeeRate *ExchangeFee `json:"feeRate,omitempty"` ExchangeFeeRates map[ExchangeName]ExchangeFee `json:"exchangeFeeRates"` // Futures data fields Isolated bool `json:"isolated"` UpdateTime int64 `json:"updateTime"` PositionRisk *PositionRisk } func NewPositionFromMarket(market Market) *Position { if len(market.BaseCurrency) == 0 || len(market.QuoteCurrency) == 0 { panic("logical exception: missing market information, base currency or quote currency is empty") } return &Position{ Symbol: market.Symbol, BaseCurrency: market.BaseCurrency, QuoteCurrency: market.QuoteCurrency, Market: market, TotalFee: make(map[string]fixedpoint.Value), } } func NewPosition(symbol, base, quote string) *Position { return &Position{ Symbol: symbol, BaseCurrency: base, QuoteCurrency: quote, TotalFee: make(map[string]fixedpoint.Value), } } func (p *Position) addTradeFee(trade Trade) { if p.TotalFee == nil { p.TotalFee = make(map[string]fixedpoint.Value) } p.TotalFee[trade.FeeCurrency] = p.TotalFee[trade.FeeCurrency].Add(trade.Fee) } func (p *Position) Reset() { p.Base = fixedpoint.Zero p.Quote = fixedpoint.Zero p.AverageCost = fixedpoint.Zero p.TotalFee = make(map[string]fixedpoint.Value) } func (p *Position) SetFeeRate(exchangeFee ExchangeFee) { p.FeeRate = &exchangeFee } func (p *Position) SetExchangeFeeRate(ex ExchangeName, exchangeFee ExchangeFee) { if p.ExchangeFeeRates == nil { p.ExchangeFeeRates = make(map[ExchangeName]ExchangeFee) } p.ExchangeFeeRates[ex] = exchangeFee } func (p *Position) IsShort() bool { return p.Base.Sign() < 0 } func (p *Position) IsLong() bool { return p.Base.Sign() > 0 } func (p *Position) IsClosed() bool { return p.Base.Sign() == 0 } func (p *Position) IsOpened(currentPrice fixedpoint.Value) bool { return !p.IsClosed() && !p.IsDust(currentPrice) } func (p *Position) Type() PositionType { if p.Base.Sign() > 0 { return PositionLong } else if p.Base.Sign() < 0 { return PositionShort } return PositionClosed } func (p *Position) SlackAttachment() slack.Attachment { p.Lock() defer p.Unlock() averageCost := p.AverageCost base := p.Base quote := p.Quote var posType = p.Type() var color = "" sign := p.Base.Sign() if sign == 0 { color = "#cccccc" } else if sign > 0 { color = "#228B22" } else if sign < 0 { color = "#DC143C" } title := templateutil.Render(string(posType)+` Position {{ .Symbol }} `, p) fields := []slack.AttachmentField{ {Title: "Average Cost", Value: averageCost.String() + " " + p.QuoteCurrency, Short: true}, {Title: p.BaseCurrency, Value: base.String(), Short: true}, {Title: p.QuoteCurrency, Value: quote.String()}, } if p.TotalFee != nil { for feeCurrency, fee := range p.TotalFee { if fee.Sign() > 0 { fields = append(fields, slack.AttachmentField{ Title: fmt.Sprintf("Fee (%s)", feeCurrency), Value: fee.String(), Short: true, }) } } } return slack.Attachment{ // Pretext: "", // Text: text, Title: title, Color: color, Fields: fields, Footer: templateutil.Render("update time {{ . }}", time.Now().Format(time.RFC822)), // FooterIcon: "", } } func (p *Position) PlainText() (msg string) { posType := p.Type() msg = fmt.Sprintf("%s Position %s: average cost = %v, base = %v, quote = %v", posType, p.Symbol, p.AverageCost, p.Base, p.Quote, ) if p.TotalFee != nil { for feeCurrency, fee := range p.TotalFee { msg += fmt.Sprintf("\nfee (%s) = %v", feeCurrency, fee) } } return msg } func (p *Position) String() string { return fmt.Sprintf("POSITION %s: average cost = %v, base = %v, quote = %v", p.Symbol, p.AverageCost, p.Base, p.Quote, ) } func (p *Position) BindStream(stream Stream) { stream.OnTradeUpdate(func(trade Trade) { if p.Symbol == trade.Symbol { p.AddTrade(trade) } }) } func (p *Position) SetClosing(c bool) bool { p.Lock() defer p.Unlock() if p.closing && c { return false } p.closing = c return true } func (p *Position) IsClosing() (c bool) { p.Lock() c = p.closing p.Unlock() return c } func (p *Position) AddTrades(trades []Trade) (fixedpoint.Value, fixedpoint.Value, bool) { var totalProfitAmount, totalNetProfit fixedpoint.Value for _, trade := range trades { if profit, netProfit, madeProfit := p.AddTrade(trade); madeProfit { totalProfitAmount = totalProfitAmount.Add(profit) totalNetProfit = totalNetProfit.Add(netProfit) } } return totalProfitAmount, totalNetProfit, !totalProfitAmount.IsZero() } func (p *Position) AddTrade(td Trade) (profit fixedpoint.Value, netProfit fixedpoint.Value, madeProfit bool) { price := td.Price quantity := td.Quantity quoteQuantity := td.QuoteQuantity fee := td.Fee // calculated fee in quote (some exchange accounts may enable platform currency fee discount, like BNB) // convert platform fee token into USD values var feeInQuote = fixedpoint.Zero switch td.FeeCurrency { case p.BaseCurrency: if !td.IsFutures { quantity = quantity.Sub(fee) } case p.QuoteCurrency: if !td.IsFutures { quoteQuantity = quoteQuantity.Sub(fee) } default: if !td.Fee.IsZero() { if p.ExchangeFeeRates != nil { if exchangeFee, ok := p.ExchangeFeeRates[td.Exchange]; ok { if td.IsMaker { feeInQuote = feeInQuote.Add(exchangeFee.MakerFeeRate.Mul(quoteQuantity)) } else { feeInQuote = feeInQuote.Add(exchangeFee.TakerFeeRate.Mul(quoteQuantity)) } } } else if p.FeeRate != nil { if td.IsMaker { feeInQuote = feeInQuote.Add(p.FeeRate.MakerFeeRate.Mul(quoteQuantity)) } else { feeInQuote = feeInQuote.Add(p.FeeRate.TakerFeeRate.Mul(quoteQuantity)) } } } } p.Lock() defer p.Unlock() // update changedAt field before we unlock in the defer func defer func() { p.ChangedAt = td.Time.Time() }() p.addTradeFee(td) // Base > 0 means we're in long position // Base < 0 means we're in short position switch td.Side { case SideTypeBuy: // was short position, now trade buy should cover the position if p.Base.Sign() < 0 { // convert short position to long position if p.Base.Add(quantity).Sign() > 0 { profit = p.AverageCost.Sub(price).Mul(p.Base.Neg()) netProfit = p.ApproximateAverageCost.Sub(price).Mul(p.Base.Neg()).Sub(feeInQuote) p.Base = p.Base.Add(quantity) p.Quote = p.Quote.Sub(quoteQuantity) p.AverageCost = price p.ApproximateAverageCost = price p.AccumulatedProfit = p.AccumulatedProfit.Add(profit) p.OpenedAt = td.Time.Time() return profit, netProfit, true } else { // after adding quantity it's still short position p.Base = p.Base.Add(quantity) p.Quote = p.Quote.Sub(quoteQuantity) profit = p.AverageCost.Sub(price).Mul(quantity) netProfit = p.ApproximateAverageCost.Sub(price).Mul(quantity).Sub(feeInQuote) p.AccumulatedProfit = p.AccumulatedProfit.Add(profit) return profit, netProfit, true } } // before adding the quantity, it's already a dust position // then we should set the openedAt time if p.IsDust(td.Price) { p.OpenedAt = td.Time.Time() } // here the case is: base == 0 or base > 0 divisor := p.Base.Add(quantity) p.ApproximateAverageCost = p.ApproximateAverageCost.Mul(p.Base). Add(quoteQuantity). Add(feeInQuote). Div(divisor) p.AverageCost = p.AverageCost.Mul(p.Base).Add(quoteQuantity).Div(divisor) p.Base = p.Base.Add(quantity) p.Quote = p.Quote.Sub(quoteQuantity) return fixedpoint.Zero, fixedpoint.Zero, false case SideTypeSell: // was long position, the sell trade should reduce the base amount if p.Base.Sign() > 0 { // convert long position to short position if p.Base.Compare(quantity) < 0 { profit = price.Sub(p.AverageCost).Mul(p.Base) netProfit = price.Sub(p.ApproximateAverageCost).Mul(p.Base).Sub(feeInQuote) p.Base = p.Base.Sub(quantity) p.Quote = p.Quote.Add(quoteQuantity) p.AverageCost = price p.ApproximateAverageCost = price p.AccumulatedProfit = p.AccumulatedProfit.Add(profit) p.OpenedAt = td.Time.Time() return profit, netProfit, true } else { p.Base = p.Base.Sub(quantity) p.Quote = p.Quote.Add(quoteQuantity) profit = price.Sub(p.AverageCost).Mul(quantity) netProfit = price.Sub(p.ApproximateAverageCost).Mul(quantity).Sub(feeInQuote) p.AccumulatedProfit = p.AccumulatedProfit.Add(profit) return profit, netProfit, true } } // before subtracting the quantity, it's already a dust position // then we should set the openedAt time if p.IsDust(td.Price) { p.OpenedAt = td.Time.Time() } // handling short position, since Base here is negative we need to reverse the sign divisor := quantity.Sub(p.Base) p.ApproximateAverageCost = p.ApproximateAverageCost.Mul(p.Base.Neg()). Add(quoteQuantity). Sub(feeInQuote). Div(divisor) p.AverageCost = p.AverageCost.Mul(p.Base.Neg()). Add(quoteQuantity). Div(divisor) p.Base = p.Base.Sub(quantity) p.Quote = p.Quote.Add(quoteQuantity) return fixedpoint.Zero, fixedpoint.Zero, false } return fixedpoint.Zero, fixedpoint.Zero, false }