diff --git a/README.md b/README.md index 2e896b34f..820d8cdd9 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ You can use BBGO's trading unit and back-test unit to implement your own strateg ### Trading Unit Developers 🧑‍💻 -You can use BBGO's underlying common exchange API, currently it supports 4+ major exchanges, so you don't have to repeat the implementation. +You can use BBGO's underlying common exchange API, currently it supports 4+ major exchanges, so you don't have to repeat +the implementation. ## Features @@ -44,37 +45,38 @@ You can use BBGO's underlying common exchange API, currently it supports 4+ majo - Built-in parameter optimization tool. - Built-in Grid strategy and many other built-in strategies. - Multi-exchange session support: you can connect to more than 2 exchanges with different accounts or subaccounts. -- Indicators with interface similar to `pandas.Series`([series](https://github.com/c9s/bbgo/blob/main/doc/development/series.md))([usage](https://github.com/c9s/bbgo/blob/main/doc/development/indicator.md)): - - [Accumulation/Distribution Indicator](./pkg/indicator/ad.go) - - [Arnaud Legoux Moving Average](./pkg/indicator/alma.go) - - [Average True Range](./pkg/indicator/atr.go) - - [Bollinger Bands](./pkg/indicator/boll.go) - - [Commodity Channel Index](./pkg/indicator/cci.go) - - [Cumulative Moving Average](./pkg/indicator/cma.go) - - [Double Exponential Moving Average](./pkg/indicator/dema.go) - - [Directional Movement Index](./pkg/indicator/dmi.go) - - [Brownian Motion's Drift Factor](./pkg/indicator/drift.go) - - [Ease of Movement](./pkg/indicator/emv.go) - - [Exponentially Weighted Moving Average](./pkg/indicator/ewma.go) - - [Hull Moving Average](./pkg/indicator/hull.go) - - [Trend Line (Tool)](./pkg/indicator/line.go) - - [Moving Average Convergence Divergence Indicator](./pkg/indicator/macd.go) - - [On-Balance Volume](./pkg/indicator/obv.go) - - [Pivot](./pkg/indicator/pivot.go) - - [Running Moving Average](./pkg/indicator/rma.go) - - [Relative Strength Index](./pkg/indicator/rsi.go) - - [Simple Moving Average](./pkg/indicator/sma.go) - - [Ehler's Super Smoother Filter](./pkg/indicator/ssf.go) - - [Stochastic Oscillator](./pkg/indicator/stoch.go) - - [SuperTrend](./pkg/indicator/supertrend.go) - - [Triple Exponential Moving Average](./pkg/indicator/tema.go) - - [Tillson T3 Moving Average](./pkg/indicator/till.go) - - [Triangular Moving Average](./pkg/indicator/tma.go) - - [Variable Index Dynamic Average](./pkg/indicator/vidya.go) - - [Volatility Indicator](./pkg/indicator/volatility.go) - - [Volume Weighted Average Price](./pkg/indicator/vwap.go) - - [Zero Lag Exponential Moving Average](./pkg/indicator/zlema.go) - - And more... +- Indicators with interface similar + to `pandas.Series`([series](https://github.com/c9s/bbgo/blob/main/doc/development/series.md))([usage](https://github.com/c9s/bbgo/blob/main/doc/development/indicator.md)): + - [Accumulation/Distribution Indicator](./pkg/indicator/ad.go) + - [Arnaud Legoux Moving Average](./pkg/indicator/alma.go) + - [Average True Range](./pkg/indicator/atr.go) + - [Bollinger Bands](./pkg/indicator/boll.go) + - [Commodity Channel Index](./pkg/indicator/cci.go) + - [Cumulative Moving Average](./pkg/indicator/cma.go) + - [Double Exponential Moving Average](./pkg/indicator/dema.go) + - [Directional Movement Index](./pkg/indicator/dmi.go) + - [Brownian Motion's Drift Factor](./pkg/indicator/drift.go) + - [Ease of Movement](./pkg/indicator/emv.go) + - [Exponentially Weighted Moving Average](./pkg/indicator/ewma.go) + - [Hull Moving Average](./pkg/indicator/hull.go) + - [Trend Line (Tool)](./pkg/indicator/line.go) + - [Moving Average Convergence Divergence Indicator](./pkg/indicator/macd.go) + - [On-Balance Volume](./pkg/indicator/obv.go) + - [Pivot](./pkg/indicator/pivot.go) + - [Running Moving Average](./pkg/indicator/rma.go) + - [Relative Strength Index](./pkg/indicator/rsi.go) + - [Simple Moving Average](./pkg/indicator/sma.go) + - [Ehler's Super Smoother Filter](./pkg/indicator/ssf.go) + - [Stochastic Oscillator](./pkg/indicator/stoch.go) + - [SuperTrend](./pkg/indicator/supertrend.go) + - [Triple Exponential Moving Average](./pkg/indicator/tema.go) + - [Tillson T3 Moving Average](./pkg/indicator/till.go) + - [Triangular Moving Average](./pkg/indicator/tma.go) + - [Variable Index Dynamic Average](./pkg/indicator/vidya.go) + - [Volatility Indicator](./pkg/indicator/volatility.go) + - [Volume Weighted Average Price](./pkg/indicator/vwap.go) + - [Zero Lag Exponential Moving Average](./pkg/indicator/zlema.go) + - And more... - HeikinAshi OHLC / Normal OHLC (check [this config](https://github.com/c9s/bbgo/blob/main/config/skeleton.yaml#L5)) - React-powered Web Dashboard. - Docker image ready. @@ -115,7 +117,8 @@ Get your exchange API key and secret after you register the accounts (you can ch - OKEx: - Kucoin: -This project is maintained and supported by a small group of team. If you would like to support this project, please register on the exchanges using the provided links with referral codes above. +This project is maintained and supported by a small group of team. If you would like to support this project, please +register on the exchanges using the provided links with referral codes above. ## Installation @@ -145,8 +148,8 @@ bash <(curl -s https://raw.githubusercontent.com/c9s/bbgo/main/scripts/download. Or refer to the [Release Page](https://github.com/c9s/bbgo/releases) and download manually. -Since v2, we've added new float point implementation from dnum to support decimals with higher precision. -To download & setup, please refer to [Dnum Installation](doc/topics/dnum-binary.md) +Since v2, we've added new float point implementation from dnum to support decimals with higher precision. To download & +setup, please refer to [Dnum Installation](doc/topics/dnum-binary.md) ### One-click Linode StackScript @@ -241,8 +244,8 @@ bbgo pnl --exchange binance --asset BTC --since "2019-01-01" ### Testnet (Paper Trading) -Currently only supports binance testnet. -To run bbgo in testnet, apply new API keys from [Binance Test Network](https://testnet.binance.vision), and set the following env before you start bbgo: +Currently only supports binance testnet. To run bbgo in testnet, apply new API keys +from [Binance Test Network](https://testnet.binance.vision), and set the following env before you start bbgo: ```bash export PAPER_TRADE=1 @@ -344,7 +347,8 @@ Check out the strategy directory [strategy](pkg/strategy) for all built-in strat indicator [bollgrid](pkg/strategy/bollgrid) - `grid` strategy implements the fixed price band grid strategy [grid](pkg/strategy/grid). See [document](./doc/strategy/grid.md). -- `supertrend` strategy uses Supertrend indicator as trend, and DEMA indicator as noise filter [supertrend](pkg/strategy/supertrend). See +- `supertrend` strategy uses Supertrend indicator as trend, and DEMA indicator as noise + filter [supertrend](pkg/strategy/supertrend). See [document](./doc/strategy/supertrend.md). - `support` strategy uses K-lines with high volume as support [support](pkg/strategy/support). See [document](./doc/strategy/support.md). @@ -365,78 +369,9 @@ bbgo run --config config/buyandhold.yaml See [Back-testing](./doc/topics/back-testing.md) -## Adding New Built-in Strategy +## Adding Strategy -Fork and clone this repository, Create a directory under `pkg/strategy/newstrategy`, write your strategy -at `pkg/strategy/newstrategy/strategy.go`. - -Define a strategy struct: - -```go -package newstrategy - -import ( - "github.com/c9s/bbgo/pkg/fixedpoint" -) - -type Strategy struct { - Symbol string `json:"symbol"` - Param1 int `json:"param1"` - Param2 int `json:"param2"` - Param3 fixedpoint.Value `json:"param3"` -} -``` - -Register your strategy: - -```go -package newstrategy - -const ID = "newstrategy" - -const stateKey = "state-v1" - -var log = logrus.WithField("strategy", ID) - -func init() { - bbgo.RegisterStrategy(ID, &Strategy{}) -} -``` - -Implement the strategy methods: - -```go -package newstrategy - -func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { - session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "2m"}) -} - -func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { - // .... - return nil -} -``` - -Edit `pkg/cmd/builtin.go`, and import the package, like this: - -```go -package cmd - -// import built-in strategies -import ( - _ "github.com/c9s/bbgo/pkg/strategy/bollgrid" - _ "github.com/c9s/bbgo/pkg/strategy/buyandhold" - _ "github.com/c9s/bbgo/pkg/strategy/flashcrash" - _ "github.com/c9s/bbgo/pkg/strategy/grid" - _ "github.com/c9s/bbgo/pkg/strategy/pricealert" - _ "github.com/c9s/bbgo/pkg/strategy/support" - _ "github.com/c9s/bbgo/pkg/strategy/swing" - _ "github.com/c9s/bbgo/pkg/strategy/trailingstop" - _ "github.com/c9s/bbgo/pkg/strategy/xmaker" - _ "github.com/c9s/bbgo/pkg/strategy/xpuremaker" -) -``` +See [Developing Strategy](./doc/topics/developing-strategy.md) ## Write your own private strategy @@ -635,8 +570,9 @@ What's Position? ## Looking For A New Strategy? -You can write an article about BBGO in any topic, in 750-1500 words for exchange, and I can implement the strategy for you (depends on the complexity and efforts). -If you're interested in, DM me in telegram or twitter , we can discuss. +You can write an article about BBGO in any topic, in 750-1500 words for exchange, and I can implement the strategy for +you (depends on the complexity and efforts). If you're interested in, DM me in telegram or +twitter , we can discuss. ## Contributing diff --git a/config/pivotshort.yaml b/config/pivotshort.yaml index 473df2cbc..ad69887e4 100644 --- a/config/pivotshort.yaml +++ b/config/pivotshort.yaml @@ -39,26 +39,30 @@ exchangeStrategies: # stopEMARange is the price range we allow short. # Short-allowed price range = [current price] > [EMA] * (1 - [stopEMARange]) - stopEMARange: 0% + # Higher the stopEMARange than higher the chance to open a short + stopEMARange: 2% stopEMA: interval: 1h window: 99 - bounceShort: - enabled: false - interval: 1h - window: 10 + resistanceShort: + enabled: true + interval: 5m + window: 80 + quantity: 10.0 - minDistance: 3% - # stopLossPercentage: 1% + + # minDistance is used to ignore the place that is too near to the current price + minDistance: 5% + groupDistance: 1% # ratio is the ratio of the resistance price, - # higher the ratio, lower the price - # first_layer_price = resistance_price * (1 - ratio) - # second_layer_price = (resistance_price * (1 - ratio)) * (2 * layerSpread) - ratio: 0% - numOfLayers: 1 - layerSpread: 0.1% + # higher the ratio, higher the sell price + # first_layer_price = resistance_price * (1 + ratio) + # second_layer_price = (resistance_price * (1 + ratio)) * (2 * layerSpread) + ratio: 1.5% + numOfLayers: 3 + layerSpread: 0.4% exits: # (0) roiStopLoss is the stop loss percentage of the position ROI (currently the price change) @@ -86,17 +90,20 @@ exchangeStrategies: # you can grab a simple stats by the following SQL: # SELECT ((close - low) / close) AS shadow_ratio FROM binance_klines WHERE symbol = 'ETHUSDT' AND `interval` = '5m' AND start_time > '2022-01-01' ORDER BY shadow_ratio DESC LIMIT 20; - lowerShadowTakeProfit: + interval: 30m + window: 99 ratio: 3% # (5) cumulatedVolumeTakeProfit is used to take profit when the cumulated quote volume from the klines exceeded a threshold - cumulatedVolumeTakeProfit: - minQuoteVolume: 100_000_000 + interval: 5m window: 2 + minQuoteVolume: 200_000_000 backtest: sessions: - binance - startTime: "2022-04-01" + startTime: "2022-01-01" endTime: "2022-06-18" symbols: - ETHUSDT diff --git a/doc/README.md b/doc/README.md index ade7d4089..869a0a2c5 100644 --- a/doc/README.md +++ b/doc/README.md @@ -26,6 +26,7 @@ * [Support](strategy/support.md) - Support strategy that buys on high volume support ### Development +* [Developing Strategy](topics/developing-strategy.md) - developing strategy * [Adding New Exchange](development/adding-new-exchange.md) - Check lists for adding new exchanges * [KuCoin Command-line Test Tool](development/kucoin-cli.md) - Kucoin command-line tools * [SQL Migration](development/migration.md) - Adding new SQL migration scripts diff --git a/doc/topics/developing-strategy.md b/doc/topics/developing-strategy.md index c28f590a1..f8bd30817 100644 --- a/doc/topics/developing-strategy.md +++ b/doc/topics/developing-strategy.md @@ -10,7 +10,91 @@ For built-in strategies, they are placed in `pkg/strategy` of the BBGO source re For external strategies, you can create a private repository as an isolated go package and place your strategy inside it. -In general, strategies are Go struct, placed in Go package. +In general, strategies are Go struct, defined in the Go package. + +## Quick Start + +To add your first strategy, the fastest way is to add it as a built-in strategy. + +Simply edit `pkg/cmd/builtin.go` and import your strategy package there. + +When BBGO starts, the strategy will be imported as a package, and register its struct to the engine. + +You can also create a new file called `pkg/cmd/builtin_short.go` and import your strategy package. + +``` +import ( + _ "github.com/c9s/bbgo/pkg/strategy/short" +) +``` + +Create a directory for your new strategy in the BBGO source code repository: + +```shell +mkdir -p pkg/strategy/short +``` + +Open a new file at `pkg/strategy/short/strategy.go` and paste the simplest strategy code: + +```go +package short + +import ( + "context" + "fmt" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/types" +) + +const ID = "short" + +func init() { + // Register our struct type to BBGO + // Note that you don't need to field the fields. + // BBGO uses reflect to parse your type information. + bbgo.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Symbol string `json:"symbol"` + Interval types.Interval `json:"interval"` +} + +func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + session.MarketDataStream.OnKLineClosed(func(k types.KLine) { + fmt.Println(k) + }) + return nil +} +``` + +This is the most simple strategy with only ~30 lines code, it subscribes to the kline channel with the given symbol from +the config, And when the kline is closed, it prints the kline to the console. + +Note that, when Run() is executed, the user data stream is not connected to the exchange yet, but the history market +data is already loaded, so if you need to submit an order on start, be sure to write your order submit code inside the +event closures like `OnKLineClosed` or `OnStart`. + +Now you can prepare your config file, create a file called `bbgo.yaml` with the following content: + +```yaml +exchangeStrategies: +- on: binance + short: + symbol: ETHUSDT + interval: 1m +``` + +And then, you should be able to run this strategy by running the following command: + +```shell +go run ./cmd/bbgo run +``` ## The Strategy Struct @@ -28,7 +112,7 @@ externalStrategies: You can write the following struct to load the symbol setting: -``` +```go package short type Strategy struct { @@ -39,7 +123,7 @@ type Strategy struct { To use the Symbol setting, you can get the value from the Run method of the strategy: -``` +```go func (s *Strategy) Run(ctx context.Context, session *bbgo.ExchangeSession) error { // you need to import the "log" package log.Println("%s", s.Symbol) @@ -47,18 +131,18 @@ func (s *Strategy) Run(ctx context.Context, session *bbgo.ExchangeSession) error } ``` -Now you have the Go struct and the Go package, but BBGO does not know your strategy, -so you need to register your strategy. +Now you have the Go struct and the Go package, but BBGO does not know your strategy, so you need to register your +strategy. Define an ID const in your package: -``` +```go const ID = "short" ``` Then call bbgo.RegisterStrategy with the ID you just defined and a struct reference: -``` +```go func init() { bbgo.RegisterStrategy(ID, &Strategy{}) } @@ -66,17 +150,354 @@ func init() { Note that you don't need to fill the fields in the struct, BBGO just need to know the type of struct. -(BBGO use reflect to parse the fields from the given struct and allocate a new struct object from the given struct type internally) +(BBGO use reflect to parse the fields from the given struct and allocate a new struct object from the given struct type +internally) +## Exchange Session -## Built-in Strategy +The `*bbgo.ExchangeSession` represents a connectivity to a crypto exchange, it's also a hub that connects to everything +you need, for example, standard indicators, account information, balance information, market data stream, user data +stream, exchange APIs, and so on. +By default, BBGO checks the environment variables that you defined to detect which exchange session to be created. +For example, environment variables like `BINANCE_API_KEY`, `BINANCE_API_SECRET` will be transformed into an exchange +session that connects to Binance. +You can not only connect to multiple different crypt exchanges, but also create multiple sessions to the same crypto +exchange with few different options. +To do that, add the following section to your `bbgo.yaml` config file: +```yaml +--- +sessions: + binance: + exchange: binance + envVarPrefix: binance + binance_cross_margin: + exchange: binance + envVarPrefix: binance + margin: true + binance_margin_ethusdt: + exchange: binance + envVarPrefix: binance + margin: true + isolatedMargin: true + isolatedMarginSymbol: ETHUSDT + okex1: + exchange: okex + envVarPrefix: okex + okex2: + exchange: okex + envVarPrefix: okex +``` +You can specify which exchange session you want to mount for each strategy in the config file, it's quiet simple: +```yaml +exchangeStrategies: +- on: binance_margin_ethusdt + short: + symbol: ETHUSDT +- on: binance_margin + foo: + symbol: ETHUSDT + +- on: binance + bar: + symbol: ETHUSDT +``` + +## Market Data Stream and User Data Stream + +When BBGO connects to the exchange, it allocates two stream objects for different purposes. + +They are: + +- MarketDataStream receives market data from the exchange, for example, KLine data (candlestick, or bars), market public + trades. +- UserDataStream receives your personal trading data, for example, orders, executed trades, balance updates and other + private information. + +To add your market data subscription to the `MarketDataStream`, you can register your subscription in the `Subscribe` of +the strategy code, for example: + +```go +func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: "1m"}) +} +``` + +Since the back-test engine is a kline-based engine, to subscribe market trades, you need to check if you're in the +back-test environment: + +```go +func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { + if !bbgo.IsBackTesting { + session.Subscribe(types.MarketTradeChannel, s.Symbol, types.SubscribeOptions{}) + } +} +``` + +To receive the market data from the market data stream, you need to register the event callback: + +```go +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + // handle closed kline event here + }) + session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { + // handle market trade event here + }) +} +``` + +In the above example, we register our event callback to the market data stream of the current exchange session, The +market data stream object here is a session-wide market data stream, so it's shared with other strategies that are also +using the same exchange session, so you might receive kline with different symbol or interval. + +It's better to add a condition to filter the kline events: + +```go +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + if kline.Symbol != s.Symbol || kline.Interval != s.Interval { + return + } + // handle your kline here + }) +} +``` + +You can also use the KLineWith method to wrap your kline closure with the filter condition: + +```go +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + session.MarketDataStream.OnKLineClosed(types.KLineWith("BTCUSDT", types.Interval1m, func(kline types.KLine) { + // handle your kline here + }) +} +``` + +Note that, when the Run() method is executed, the user data stream and market data stream are not connected yet. + +## Submitting Orders + +To place an order, you can call `SubmitOrders` exchange API: + +```go +createdOrders, err := session.Exchange.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: "BTCUSDT", + Type: types.OrderTypeLimit, + Price: fixedpoint.NewFromFloat(18000.0), + Quantity: fixedpoint.NewFromFloat(1.0), +}) +if err != nil { + log.WithError(err).Errorf("can not submit orders") +} + +log.Infof("createdOrders: %+v", createdOrders) +``` + +There are some pre-defined order types you can use: + +- `types.OrderTypeLimit` +- `types.OrderTypeMarket` +- `types.OrderTypeStopMarket` +- `types.OrderTypeStopLimit` +- `types.OrderTypeLimitMaker` - forces the order to be a maker. + +Although it's crypto market, the above order types are actually derived from the stock market: + +A limit order is an order to buy or sell a stock with a restriction on the maximum price to be paid or the minimum price +to be received (the "limit price"). If the order is filled, it will only be at the specified limit price or better. +However, there is no assurance of execution. A limit order may be appropriate when you think you can buy at a price +lower than--or sell at a price higher than -- the current quote. + +A market order is an order to buy or sell a stock at the market's current best available price. A market order typically +ensures an execution, but it does not guarantee a specified price. Market orders are optimal when the primary goal is to +execute the trade immediately. A market order is generally appropriate when you think a stock is priced right, when you +are sure you want a fill on your order, or when you want an immediate execution. + +A stop order is an order to buy or sell a stock at the market price once the stock has traded at or through a specified +price (the "stop price"). If the stock reaches the stop price, the order becomes a market order and is filled at the +next available market price. + +## UserDataStream + +UserDataStream is an authenticated connection to the crypto exchange. You can receive the following data type from the +user data stream: + +- OrderUpdate +- TradeUpdate +- BalanceUpdate + +When you submit an order to the exchange, you might want to know when the order is filled or not, user data stream is +the real time notification let you receive the order update event. + +To get the order update from the user data stream: + +```go +session.UserDataStream.OnOrderUpdate(func(order types.Order) { + if order.Status == types.OrderStatusFilled { + log.Infof("your order is filled: %+v", order) + } +}) +``` + +However, order update only contains status, price, quantity of the order, if you're submitting market order, you won't know +the actual price of the order execution. + +One order can be filled by different size trades from the market, by collecting the trades, you can calculate the +average price of the order execution and the total trading fee that you used for the order. + +If you need to get the details of the trade execution. you need the trade update event: + +```go +session.UserDataStream.OnTrade(func(trade types.Trade) { + log.Infof("trade price %f, fee %f %s", trade.Price.Float64(), trade.Fee.Float64(), trade.FeeCurrency) +}) +``` + +To monitor your balance change, you can use the balance update event callback: + +```go +session.UserDataStream.OnBalanceUpdate(func(balances types.BalanceMap) { + log.Infof("balance update: %+v", balances) +}) +``` + +Note that, as we mentioned above, the user data stream is a session-wide stream, that means you might receive the order update event for other strategies. + +To prevent that, you need to manage your active order for your strategy: + +```go +activeBook := bbgo.NewActiveOrderBook("BTCUSDT") +activeBook.Bind(session.UserDataStream) +``` + +Then, when you create some orders, you can register your order to the active order book, so that it can manage the order +update: + +```go +createdOrders, err := session.Exchange.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: "BTCUSDT", + Type: types.OrderTypeLimit, + Price: fixedpoint.NewFromFloat(18000.0), + Quantity: fixedpoint.NewFromFloat(1.0), +}) +if err != nil { + log.WithError(err).Errorf("can not submit orders") +} + +activeBook.Add(createdOrders...) +``` + +## Notification + +You can use the notification API to send notification to Telegram or Slack: + +```go +bbgo.Notify(message) +bbgo.Notify(message, objs...) +bbgo.Notify(format, arg1, arg2, arg3, objs...) +bbgo.Notify(object, object2, object3) +``` + +Note that, if you're using the third format, simple arguments (float, bool, string... etc) will be used for calling the +fmt.Sprintf, and the extra arguments will be rendered as attachments. + +For example: + +```go +bbgo.Notify("%s found support price: %f", "BTCUSDT", 19000.0, kline) +``` + +The above call will render the first format string with the given float number 19000, and then attach the kline object as the attachment. + +## Handling Trades and Profit + +In order to manage the trades and orders for each strategy, BBGO designed an order executor API that helps you collect +the related trades and orders from the strategy, so trades from other strategies won't bother your logics. + +To do that, you can use the *bbgo.GeneralOrderExecutor: + +```go +var profitStats = types.NewProfitStats(s.Market) +var position = types.NewPositionFromMarket(s.Market) +var tradeStats = &types.TradeStats{} +orderExecutor := bbgo.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, position) + +// bind the trade events to update the profit stats +orderExecutor.BindProfitStats(profitStats) + +// bind the trade events to update the trade stats +orderExecutor.BindTradeStats(tradeStats) +orderExecutor.Bind() +``` + +## Graceful Shutdown + +When BBGO shuts down, you might want to clean up your open orders for your strategy, to do that, you can use the +OnShutdown API to register your handler. + +```go +bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) + if err := s.orderExecutor.GracefulCancel(ctx) ; err != nil { + log.WithError(err).Error("graceful cancel order error") + } +}) +``` + +## Persistence + +When you need to adjust the parameters and restart BBGO process, everything in the memory will be reset after the +restart, how can we keep these data? + +Although BBGO is written in Golang, BBGO provides a useful dynamic system to help you persist your data. + +If you have some state needs to preserve before shutting down, you can simply add the `persistence` struct tag to the field, +and BBGO will automatically save and restore your state. For example, + +```go +type Strategy struct { + Position *types.Position `persistence:"position"` + ProfitStats *types.ProfitStats `persistence:"profit_stats"` + TradeStats *types.TradeStats `persistence:"trade_stats"` +} +``` + +And remember to add the `persistence` section in your bbgo.yaml config: + +```yaml +persistence: + redis: + host: 127.0.0.1 + port: 6379 + db: 0 +``` + +In the Run method of your strategy, you need to check if these fields are nil, and you need to initialize them: + +```go + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + if s.ProfitStats == nil { + s.ProfitStats = types.NewProfitStats(s.Market) + } + + if s.TradeStats == nil { + s.TradeStats = types.NewTradeStats(s.Symbol) + } +``` + +That's it. Hit Ctrl-C and you should see BBGO saving your strategy states. diff --git a/go.mod b/go.mod index d1ce9dbf6..34bd47ce8 100644 --- a/go.mod +++ b/go.mod @@ -75,6 +75,7 @@ require ( github.com/go-test/deep v1.0.6 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect + github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect @@ -116,11 +117,13 @@ require ( go.opentelemetry.io/otel/trace v0.19.0 // indirect go.uber.org/atomic v1.9.0 // indirect golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect + golang.org/x/mod v0.5.1 // indirect golang.org/x/net v0.0.0-20220403103023-749bd193bc2b // indirect golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/tools v0.1.9 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf // indirect gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 5aa950f06..96f942c4d 100644 --- a/go.sum +++ b/go.sum @@ -194,7 +194,10 @@ github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFU github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -330,8 +333,6 @@ github.com/lestrrat-go/strftime v1.0.0/go.mod h1:E1nN3pCbtMSu1yjSVeyuRFVm/U0xoR7 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.9.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.10.5 h1:J+gdV2cUmX7ZqL2B0lFcW0m+egaHC2V3lpO8nWxyYiQ= -github.com/lib/pq v1.10.5/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs= github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= @@ -525,6 +526,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= @@ -601,6 +603,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -782,6 +786,7 @@ golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.9 h1:j9KsMiaP1c3B0OTQGth0/k+miLGTgLsAFUCrF2vLcF8= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= diff --git a/pkg/backtest/exchange.go b/pkg/backtest/exchange.go index 2f66b0d87..d076cfba7 100644 --- a/pkg/backtest/exchange.go +++ b/pkg/backtest/exchange.go @@ -50,15 +50,15 @@ var log = logrus.WithField("cmd", "backtest") var ErrUnimplemented = errors.New("unimplemented method") type Exchange struct { - sourceName types.ExchangeName - publicExchange types.Exchange - srv *service.BacktestService - startTime, endTime time.Time + sourceName types.ExchangeName + publicExchange types.Exchange + srv *service.BacktestService + currentTime time.Time account *types.Account config *bbgo.Backtest - UserDataStream, MarketDataStream types.StandardStreamEmitter + MarketDataStream types.StandardStreamEmitter trades map[string][]types.Trade tradesMutex sync.Mutex @@ -72,20 +72,6 @@ type Exchange struct { markets types.MarketMap } -func (e *Exchange) QueryOrder(ctx context.Context, q types.OrderQuery) (*types.Order, error) { - book := e.matchingBooks[q.Symbol] - oid, err := strconv.ParseUint(q.OrderID, 10, 64) - if err != nil { - return nil, err - } - - order, ok := book.getOrder(oid) - if ok { - return &order, nil - } - return nil, nil -} - func NewExchange(sourceName types.ExchangeName, sourceExchange types.Exchange, srv *service.BacktestService, config *bbgo.Backtest) (*Exchange, error) { ex := sourceExchange @@ -94,14 +80,7 @@ func NewExchange(sourceName types.ExchangeName, sourceExchange types.Exchange, s return nil, err } - var startTime, endTime time.Time - startTime = config.StartTime.Time() - if config.EndTime != nil { - endTime = config.EndTime.Time() - } else { - endTime = time.Now() - } - + startTime := config.StartTime.Time() configAccount := config.GetAccount(sourceName.String()) account := &types.Account{ @@ -120,8 +99,7 @@ func NewExchange(sourceName types.ExchangeName, sourceExchange types.Exchange, s srv: srv, config: config, account: account, - startTime: startTime, - endTime: endTime, + currentTime: startTime, closedOrders: make(map[string][]types.Order), trades: make(map[string][]types.Trade), } @@ -159,7 +137,7 @@ func (e *Exchange) addMatchingBook(symbol string, market types.Market) { func (e *Exchange) _addMatchingBook(symbol string, market types.Market) { e.matchingBooks[symbol] = &SimplePriceMatching{ - CurrentTime: e.startTime, + CurrentTime: e.currentTime, Account: e.account, Market: market, closedOrders: make(map[uint64]types.Order), @@ -172,10 +150,21 @@ func (e *Exchange) NewStream() types.Stream { } } -func (e *Exchange) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (createdOrders types.OrderSlice, err error) { - if e.UserDataStream == nil { - return createdOrders, fmt.Errorf("SubmitOrders should be called after UserDataStream been initialized") +func (e *Exchange) QueryOrder(ctx context.Context, q types.OrderQuery) (*types.Order, error) { + book := e.matchingBooks[q.Symbol] + oid, err := strconv.ParseUint(q.OrderID, 10, 64) + if err != nil { + return nil, err } + + order, ok := book.getOrder(oid) + if ok { + return &order, nil + } + return nil, nil +} + +func (e *Exchange) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (createdOrders types.OrderSlice, err error) { for _, order := range orders { symbol := order.Symbol matching, ok := e.matchingBook(symbol) @@ -196,8 +185,6 @@ func (e *Exchange) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder case types.OrderStatusFilled, types.OrderStatusCanceled, types.OrderStatusRejected: e.addClosedOrder(*createdOrder) } - - e.UserDataStream.EmitOrderUpdate(*createdOrder) } } @@ -223,20 +210,15 @@ func (e *Exchange) QueryClosedOrders(ctx context.Context, symbol string, since, } func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) error { - if e.UserDataStream == nil { - return fmt.Errorf("CancelOrders should be called after UserDataStream been initialized") - } for _, order := range orders { matching, ok := e.matchingBook(order.Symbol) if !ok { return fmt.Errorf("matching engine is not initialized for symbol %s", order.Symbol) } - canceledOrder, err := matching.CancelOrder(order) + _, err := matching.CancelOrder(order) if err != nil { return err } - - e.UserDataStream.EmitOrderUpdate(canceledOrder) } return nil @@ -318,21 +300,21 @@ func (e *Exchange) matchingBook(symbol string) (*SimplePriceMatching, bool) { return m, ok } -func (e *Exchange) InitMarketData() { - e.UserDataStream.OnTradeUpdate(func(trade types.Trade) { +func (e *Exchange) BindUserData(userDataStream types.StandardStreamEmitter) { + userDataStream.OnTradeUpdate(func(trade types.Trade) { e.addTrade(trade) }) e.matchingBooksMutex.Lock() for _, matching := range e.matchingBooks { - matching.OnTradeUpdate(e.UserDataStream.EmitTradeUpdate) - matching.OnOrderUpdate(e.UserDataStream.EmitOrderUpdate) - matching.OnBalanceUpdate(e.UserDataStream.EmitBalanceUpdate) + matching.OnTradeUpdate(userDataStream.EmitTradeUpdate) + matching.OnOrderUpdate(userDataStream.EmitOrderUpdate) + matching.OnBalanceUpdate(userDataStream.EmitBalanceUpdate) } e.matchingBooksMutex.Unlock() } -func (e *Exchange) SubscribeMarketData(extraIntervals ...types.Interval) (chan types.KLine, error) { +func (e *Exchange) SubscribeMarketData(startTime, endTime time.Time, extraIntervals ...types.Interval) (chan types.KLine, error) { log.Infof("collecting backtest configurations...") loadedSymbols := map[string]struct{}{} @@ -371,7 +353,7 @@ func (e *Exchange) SubscribeMarketData(extraIntervals ...types.Interval) (chan t log.Infof("using symbols: %v and intervals: %v for back-testing", symbols, intervals) log.Infof("querying klines from database...") - klineC, errC := e.srv.QueryKLinesCh(e.startTime, e.endTime, e, symbols, intervals) + klineC, errC := e.srv.QueryKLinesCh(startTime, endTime, e, symbols, intervals) go func() { if err := <-errC; err != nil { log.WithError(err).Error("backtest data feed error") @@ -382,6 +364,8 @@ func (e *Exchange) SubscribeMarketData(extraIntervals ...types.Interval) (chan t func (e *Exchange) ConsumeKLine(k types.KLine) { if k.Interval == types.Interval1m { + e.currentTime = k.EndTime.Time() + matching, ok := e.matchingBook(k.Symbol) if !ok { log.Errorf("matching book of %s is not initialized", k.Symbol) diff --git a/pkg/backtest/matching.go b/pkg/backtest/matching.go index 6b7b75185..ad40a567a 100644 --- a/pkg/backtest/matching.go +++ b/pkg/backtest/matching.go @@ -99,7 +99,6 @@ func (m *SimplePriceMatching) CancelOrder(o types.Order) (types.Order, error) { } m.askOrders = orders m.mu.Unlock() - } if !found { @@ -191,6 +190,8 @@ func (m *SimplePriceMatching) PlaceOrder(o types.SubmitOrder) (*types.Order, *ty order2.ExecutedQuantity = order2.Quantity order2.IsWorking = false + m.EmitOrderUpdate(order2) + // let the exchange emit the "FILLED" order update (we need the closed order) // m.EmitOrderUpdate(order2) return &order2, &trade, nil @@ -570,6 +571,7 @@ func (m *SimplePriceMatching) getOrder(orderID uint64) (types.Order, bool) { func (m *SimplePriceMatching) processKLine(kline types.KLine) { m.CurrentTime = kline.EndTime.Time() + if m.LastPrice.IsZero() { m.LastPrice = kline.Open } else { diff --git a/pkg/bbgo/activeorderbook.go b/pkg/bbgo/activeorderbook.go index e720e46fd..ba13b6293 100644 --- a/pkg/bbgo/activeorderbook.go +++ b/pkg/bbgo/activeorderbook.go @@ -131,8 +131,6 @@ func (b *ActiveOrderBook) orderUpdateHandler(order types.Order) { return } - log.Debugf("[ActiveOrderBook] received order update: %+v", order) - switch order.Status { case types.OrderStatusFilled: // make sure we have the order and we remove it diff --git a/pkg/bbgo/config.go b/pkg/bbgo/config.go index 8a2eeb3e6..8c34c6232 100644 --- a/pkg/bbgo/config.go +++ b/pkg/bbgo/config.go @@ -13,6 +13,7 @@ import ( "gopkg.in/yaml.v3" "github.com/c9s/bbgo/pkg/datatype" + "github.com/c9s/bbgo/pkg/dynamic" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/service" "github.com/c9s/bbgo/pkg/types" @@ -387,7 +388,7 @@ func (c *Config) GetSignature() string { id := strategy.ID() ps = append(ps, id) - if symbol, ok := isSymbolBasedStrategy(reflect.ValueOf(strategy)); ok { + if symbol, ok := dynamic.LookupSymbolField(reflect.ValueOf(strategy)); ok { ps = append(ps, symbol) } } diff --git a/pkg/bbgo/exit.go b/pkg/bbgo/exit.go index b931de880..a88e3f44d 100644 --- a/pkg/bbgo/exit.go +++ b/pkg/bbgo/exit.go @@ -3,44 +3,80 @@ package bbgo import ( "reflect" - "github.com/c9s/bbgo/pkg/types" + "github.com/pkg/errors" + + "github.com/c9s/bbgo/pkg/dynamic" ) +type ExitMethodSet []ExitMethod + +func (s *ExitMethodSet) SetAndSubscribe(session *ExchangeSession, parent interface{}) { + for i := range *s { + m := (*s)[i] + + // manually inherit configuration from strategy + m.Inherit(parent) + m.Subscribe(session) + } +} + type ExitMethod struct { RoiStopLoss *RoiStopLoss `json:"roiStopLoss"` ProtectiveStopLoss *ProtectiveStopLoss `json:"protectiveStopLoss"` RoiTakeProfit *RoiTakeProfit `json:"roiTakeProfit"` LowerShadowTakeProfit *LowerShadowTakeProfit `json:"lowerShadowTakeProfit"` CumulatedVolumeTakeProfit *CumulatedVolumeTakeProfit `json:"cumulatedVolumeTakeProfit"` + TrailingStop *TrailingStop2 `json:"trailingStop"` } -func (m *ExitMethod) Subscribe() { - // TODO: pull out this implementation as a simple function to reflect.go - rv := reflect.ValueOf(m) - rt := reflect.TypeOf(m) - - rv = rv.Elem() - rt = rt.Elem() - infType := reflect.TypeOf((*types.Subscriber)(nil)).Elem() - for i := 0; i < rt.NumField(); i++ { - fieldType := rt.Field(i) - if fieldType.Type.Implements(infType) { - method := rv.Field(i).MethodByName("Subscribe") - method.Call(nil) +// Inherit is used for inheriting properties from the given strategy struct +// for example, some exit method requires the default interval and symbol name from the strategy param object +func (m *ExitMethod) Inherit(parent interface{}) { + // we need to pass some information from the strategy configuration to the exit methods, like symbol, interval and window + rt := reflect.TypeOf(m).Elem() + rv := reflect.ValueOf(m).Elem() + for j := 0; j < rv.NumField(); j++ { + if !rt.Field(j).IsExported() { + continue } + + fieldValue := rv.Field(j) + if fieldValue.Kind() == reflect.Ptr && fieldValue.IsNil() { + continue + } + + dynamic.InheritStructValues(fieldValue.Interface(), parent) + } +} + +func (m *ExitMethod) Subscribe(session *ExchangeSession) { + if err := dynamic.CallStructFieldsMethod(m, "Subscribe", session); err != nil { + panic(errors.Wrap(err, "dynamic Subscribe call failed")) } } func (m *ExitMethod) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { if m.ProtectiveStopLoss != nil { m.ProtectiveStopLoss.Bind(session, orderExecutor) - } else if m.RoiStopLoss != nil { + } + + if m.RoiStopLoss != nil { m.RoiStopLoss.Bind(session, orderExecutor) - } else if m.RoiTakeProfit != nil { + } + + if m.RoiTakeProfit != nil { m.RoiTakeProfit.Bind(session, orderExecutor) - } else if m.LowerShadowTakeProfit != nil { + } + + if m.LowerShadowTakeProfit != nil { m.LowerShadowTakeProfit.Bind(session, orderExecutor) - } else if m.CumulatedVolumeTakeProfit != nil { + } + + if m.CumulatedVolumeTakeProfit != nil { m.CumulatedVolumeTakeProfit.Bind(session, orderExecutor) } + + if m.TrailingStop != nil { + m.TrailingStop.Bind(session, orderExecutor) + } } diff --git a/pkg/bbgo/exit_cumulated_volume_take_profit.go b/pkg/bbgo/exit_cumulated_volume_take_profit.go index 77c855c4b..8ba7ed65f 100644 --- a/pkg/bbgo/exit_cumulated_volume_take_profit.go +++ b/pkg/bbgo/exit_cumulated_volume_take_profit.go @@ -3,6 +3,8 @@ package bbgo import ( "context" + log "github.com/sirupsen/logrus" + "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/types" ) @@ -15,7 +17,10 @@ import ( // > SELECT start_time, `interval`, quote_volume, open, close FROM binance_klines WHERE symbol = 'ETHUSDT' AND `interval` = '5m' ORDER BY quote_volume DESC LIMIT 20; // type CumulatedVolumeTakeProfit struct { + Symbol string `json:"symbol"` + types.IntervalWindow + Ratio fixedpoint.Value `json:"ratio"` MinQuoteVolume fixedpoint.Value `json:"minQuoteVolume"` @@ -31,11 +36,7 @@ func (s *CumulatedVolumeTakeProfit) Bind(session *ExchangeSession, orderExecutor store, _ := session.MarketDataStore(position.Symbol) - session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { - if kline.Symbol != position.Symbol || kline.Interval != types.Interval1m { - return - } - + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { closePrice := kline.Close if position.IsClosed() || position.IsDust(closePrice) { return @@ -46,25 +47,33 @@ func (s *CumulatedVolumeTakeProfit) Bind(session *ExchangeSession, orderExecutor return } - if klines, ok := store.KLinesOfInterval(s.Interval); ok { - var cbv = fixedpoint.Zero - var cqv = fixedpoint.Zero - for i := 0; i < s.Window; i++ { - last := (*klines)[len(*klines)-1-i] - cqv = cqv.Add(last.QuoteVolume) - cbv = cbv.Add(last.Volume) - } - - if cqv.Compare(s.MinQuoteVolume) > 0 { - Notify("%s TakeProfit triggered by cumulated volume (window: %d) %f > %f, price = %f", - position.Symbol, - s.Window, - cqv.Float64(), - s.MinQuoteVolume.Float64(), kline.Close.Float64()) - - _ = orderExecutor.ClosePosition(context.Background(), fixedpoint.One, "cumulatedVolumeTakeProfit") - return - } + klines, ok := store.KLinesOfInterval(s.Interval) + if !ok { + log.Warnf("history kline not found") + return } - }) + + if len(*klines) < s.Window { + return + } + + var cbv = fixedpoint.Zero + var cqv = fixedpoint.Zero + for i := 0; i < s.Window; i++ { + last := (*klines)[len(*klines)-1-i] + cqv = cqv.Add(last.QuoteVolume) + cbv = cbv.Add(last.Volume) + } + + if cqv.Compare(s.MinQuoteVolume) > 0 { + Notify("%s TakeProfit triggered by cumulated volume (window: %d) %f > %f, price = %f", + position.Symbol, + s.Window, + cqv.Float64(), + s.MinQuoteVolume.Float64(), kline.Close.Float64()) + + _ = orderExecutor.ClosePosition(context.Background(), fixedpoint.One, "cumulatedVolumeTakeProfit") + return + } + })) } diff --git a/pkg/bbgo/exit_lower_shadow_take_profit.go b/pkg/bbgo/exit_lower_shadow_take_profit.go index a6008223e..e12fd5936 100644 --- a/pkg/bbgo/exit_lower_shadow_take_profit.go +++ b/pkg/bbgo/exit_lower_shadow_take_profit.go @@ -8,22 +8,31 @@ import ( ) type LowerShadowTakeProfit struct { - Ratio fixedpoint.Value `json:"ratio"` + // inherit from the strategy + types.IntervalWindow + // inherit from the strategy + Symbol string `json:"symbol"` + + Ratio fixedpoint.Value `json:"ratio"` session *ExchangeSession orderExecutor *GeneralOrderExecutor } +func (s *LowerShadowTakeProfit) Subscribe(session *ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + func (s *LowerShadowTakeProfit) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { s.session = session s.orderExecutor = orderExecutor - position := orderExecutor.Position() - session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { - if kline.Symbol != position.Symbol || kline.Interval != types.Interval1m { - return - } + stdIndicatorSet, _ := session.StandardIndicatorSet(s.Symbol) + ewma := stdIndicatorSet.EWMA(s.IntervalWindow) + + position := orderExecutor.Position() + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { closePrice := kline.Close if position.IsClosed() || position.IsDust(closePrice) { return @@ -38,6 +47,11 @@ func (s *LowerShadowTakeProfit) Bind(session *ExchangeSession, orderExecutor *Ge return } + // skip close price higher than the ewma + if closePrice.Float64() > ewma.Last() { + return + } + if kline.GetLowerShadowHeight().Div(kline.Close).Compare(s.Ratio) > 0 { Notify("%s TakeProfit triggered by shadow ratio %f, price = %f", position.Symbol, @@ -48,5 +62,5 @@ func (s *LowerShadowTakeProfit) Bind(session *ExchangeSession, orderExecutor *Ge _ = orderExecutor.ClosePosition(context.Background(), fixedpoint.One) return } - }) + })) } diff --git a/pkg/bbgo/exit_roi_stop_loss.go b/pkg/bbgo/exit_roi_stop_loss.go index 1cfb386a6..875934f41 100644 --- a/pkg/bbgo/exit_roi_stop_loss.go +++ b/pkg/bbgo/exit_roi_stop_loss.go @@ -8,24 +8,26 @@ import ( ) type RoiStopLoss struct { + Symbol string Percentage fixedpoint.Value `json:"percentage"` session *ExchangeSession orderExecutor *GeneralOrderExecutor } +func (s *RoiStopLoss) Subscribe(session *ExchangeSession) { + // use 1m kline to handle roi stop + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: types.Interval1m}) +} + func (s *RoiStopLoss) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { s.session = session s.orderExecutor = orderExecutor position := orderExecutor.Position() - session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { - if kline.Symbol != position.Symbol || kline.Interval != types.Interval1m { - return - } - + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, types.Interval1m, func(kline types.KLine) { s.checkStopPrice(kline.Close, position) - }) + })) if !IsBackTesting { session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { diff --git a/pkg/bbgo/exit_test.go b/pkg/bbgo/exit_test.go index 607eda489..12fda5bec 100644 --- a/pkg/bbgo/exit_test.go +++ b/pkg/bbgo/exit_test.go @@ -4,5 +4,5 @@ import "testing" func TestExitMethod(t *testing.T) { em := &ExitMethod{} - em.Subscribe() + em.Subscribe(&ExchangeSession{}) } diff --git a/pkg/bbgo/exit_trailing_stop.go b/pkg/bbgo/exit_trailing_stop.go new file mode 100644 index 000000000..993c25ce0 --- /dev/null +++ b/pkg/bbgo/exit_trailing_stop.go @@ -0,0 +1,157 @@ +package bbgo + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +type TrailingStop2 struct { + Symbol string + + // CallbackRate is the callback rate from the previous high price + CallbackRate fixedpoint.Value `json:"callbackRate,omitempty"` + + ActivationRatio fixedpoint.Value `json:"activationRatio,omitempty"` + + // ClosePosition is a percentage of the position to be closed + ClosePosition fixedpoint.Value `json:"closePosition,omitempty"` + + // MinProfit is the percentage of the minimum profit ratio. + // Stop order will be activated only when the price reaches above this threshold. + MinProfit fixedpoint.Value `json:"minProfit,omitempty"` + + // Interval is the time resolution to update the stop order + // KLine per Interval will be used for updating the stop order + Interval types.Interval `json:"interval,omitempty"` + + Side types.SideType `json:"side,omitempty"` + + latestHigh fixedpoint.Value + + // activated: when the price reaches the min profit price, we set the activated to true to enable trailing stop + activated bool + + // private fields + session *ExchangeSession + orderExecutor *GeneralOrderExecutor +} + +func (s *TrailingStop2) Subscribe(session *ExchangeSession) { + // use 1m kline to handle roi stop + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *TrailingStop2) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + s.latestHigh = fixedpoint.Zero + + position := orderExecutor.Position() + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + if err := s.checkStopPrice(kline.Close, position); err != nil { + log.WithError(err).Errorf("error") + } + })) + + if !IsBackTesting { + session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { + if trade.Symbol != position.Symbol { + return + } + + if err := s.checkStopPrice(trade.Price, position); err != nil { + log.WithError(err).Errorf("error") + } + }) + } +} + +func (s *TrailingStop2) getRatio(price fixedpoint.Value, position *types.Position) (fixedpoint.Value, error) { + switch s.Side { + case types.SideTypeBuy: + // for short position + return position.AverageCost.Sub(price).Div(price), nil + case types.SideTypeSell: + return price.Sub(position.AverageCost).Div(position.AverageCost), nil + } + + return fixedpoint.Zero, fmt.Errorf("unexpected side type: %v", s.Side) +} + +func (s *TrailingStop2) checkStopPrice(price fixedpoint.Value, position *types.Position) error { + if position.IsClosed() || position.IsDust(price) { + return nil + } + + if !s.MinProfit.IsZero() { + // check if we have the minimal profit + roi := position.ROI(price) + if roi.Compare(s.MinProfit) >= 0 { + Notify("[trailingStop] activated: ROI %f > minimal profit ratio %f", roi.Float64(), s.MinProfit.Float64()) + s.activated = true + } + } else if !s.ActivationRatio.IsZero() { + ratio, err := s.getRatio(price, position) + if err != nil { + return err + } + + if ratio.Compare(s.ActivationRatio) >= 0 { + s.activated = true + } + } + + // update the latest high for the sell order, or the latest low for the buy order + if s.latestHigh.IsZero() { + s.latestHigh = price + } else { + switch s.Side { + case types.SideTypeBuy: + s.latestHigh = fixedpoint.Min(price, s.latestHigh) + case types.SideTypeSell: + s.latestHigh = fixedpoint.Max(price, s.latestHigh) + } + } + + if !s.activated { + return nil + } + + switch s.Side { + case types.SideTypeBuy: + s.latestHigh = fixedpoint.Min(price, s.latestHigh) + + change := price.Sub(s.latestHigh).Div(s.latestHigh) + if change.Compare(s.CallbackRate) >= 0 { + // submit order + return s.triggerStop(price) + } + + case types.SideTypeSell: + s.latestHigh = fixedpoint.Max(price, s.latestHigh) + + change := s.latestHigh.Sub(price).Div(price) + if change.Compare(s.CallbackRate) >= 0 { + // submit order + return s.triggerStop(price) + } + } + + return nil +} + +func (s *TrailingStop2) triggerStop(price fixedpoint.Value) error { + // reset activated flag + defer func() { + s.activated = false + s.latestHigh = fixedpoint.Zero + }() + Notify("[TrailingStop] %s stop loss triggered. price: %f callback rate: %f", s.Symbol, price.Float64(), s.CallbackRate.Float64()) + ctx := context.Background() + return s.orderExecutor.ClosePosition(ctx, fixedpoint.One, "trailingStop") +} diff --git a/pkg/bbgo/exit_trailing_stop_test.go b/pkg/bbgo/exit_trailing_stop_test.go new file mode 100644 index 000000000..385d89363 --- /dev/null +++ b/pkg/bbgo/exit_trailing_stop_test.go @@ -0,0 +1,182 @@ +package bbgo + +import ( + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + "github.com/c9s/bbgo/pkg/types/mocks" +) + +// getTestMarket returns the BTCUSDT market information +// for tests, we always use BTCUSDT +func getTestMarket() types.Market { + market := types.Market{ + Symbol: "BTCUSDT", + PricePrecision: 8, + VolumePrecision: 8, + QuoteCurrency: "USDT", + BaseCurrency: "BTC", + MinNotional: fixedpoint.MustNewFromString("0.001"), + MinAmount: fixedpoint.MustNewFromString("10.0"), + MinQuantity: fixedpoint.MustNewFromString("0.001"), + } + return market +} + +func TestTrailingStop_ShortPosition(t *testing.T) { + market := getTestMarket() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockEx := mocks.NewMockExchange(mockCtrl) + mockEx.EXPECT().NewStream().Return(&types.StandardStream{}).Times(2) + mockEx.EXPECT().SubmitOrders(gomock.Any(), types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Market: market, + Quantity: fixedpoint.NewFromFloat(1.0), + Tag: "trailingStop", + }) + + session := NewExchangeSession("test", mockEx) + assert.NotNil(t, session) + + session.markets[market.Symbol] = market + + position := types.NewPositionFromMarket(market) + position.AverageCost = fixedpoint.NewFromFloat(20000.0) + position.Base = fixedpoint.NewFromFloat(-1.0) + + orderExecutor := NewGeneralOrderExecutor(session, "BTCUSDT", "test", "test-01", position) + + activationRatio := fixedpoint.NewFromFloat(0.01) + callbackRatio := fixedpoint.NewFromFloat(0.01) + stop := &TrailingStop2{ + Symbol: "BTCUSDT", + Interval: types.Interval1m, + Side: types.SideTypeBuy, + CallbackRate: callbackRatio, + ActivationRatio: activationRatio, + } + stop.Bind(session, orderExecutor) + + // the same price + currentPrice := fixedpoint.NewFromFloat(20000.0) + err := stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.False(t, stop.activated) + } + + // 20000 - 1% = 19800 + currentPrice = currentPrice.Mul(one.Sub(activationRatio)) + assert.Equal(t, fixedpoint.NewFromFloat(19800.0), currentPrice) + + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.True(t, stop.activated) + assert.Equal(t, fixedpoint.NewFromFloat(19800.0), stop.latestHigh) + } + + // 19800 - 1% = 19602 + currentPrice = currentPrice.Mul(one.Sub(callbackRatio)) + assert.Equal(t, fixedpoint.NewFromFloat(19602.0), currentPrice) + + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.Equal(t, fixedpoint.NewFromFloat(19602.0), stop.latestHigh) + assert.True(t, stop.activated) + } + + // 19602 + 1% = 19798.02 + currentPrice = currentPrice.Mul(one.Add(callbackRatio)) + assert.Equal(t, fixedpoint.NewFromFloat(19798.02), currentPrice) + + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.Equal(t, fixedpoint.Zero, stop.latestHigh) + assert.False(t, stop.activated) + } +} + +func TestTrailingStop_LongPosition(t *testing.T) { + market := getTestMarket() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockEx := mocks.NewMockExchange(mockCtrl) + mockEx.EXPECT().NewStream().Return(&types.StandardStream{}).Times(2) + mockEx.EXPECT().SubmitOrders(gomock.Any(), types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeSell, + Type: types.OrderTypeMarket, + Market: market, + Quantity: fixedpoint.NewFromFloat(1.0), + Tag: "trailingStop", + }) + + session := NewExchangeSession("test", mockEx) + assert.NotNil(t, session) + + session.markets[market.Symbol] = market + + position := types.NewPositionFromMarket(market) + position.AverageCost = fixedpoint.NewFromFloat(20000.0) + position.Base = fixedpoint.NewFromFloat(1.0) + + orderExecutor := NewGeneralOrderExecutor(session, "BTCUSDT", "test", "test-01", position) + + activationRatio := fixedpoint.NewFromFloat(0.01) + callbackRatio := fixedpoint.NewFromFloat(0.01) + stop := &TrailingStop2{ + Symbol: "BTCUSDT", + Interval: types.Interval1m, + Side: types.SideTypeSell, + CallbackRate: callbackRatio, + ActivationRatio: activationRatio, + } + stop.Bind(session, orderExecutor) + + // the same price + currentPrice := fixedpoint.NewFromFloat(20000.0) + err := stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.False(t, stop.activated) + } + + // 20000 + 1% = 20200 + currentPrice = currentPrice.Mul(one.Add(activationRatio)) + assert.Equal(t, fixedpoint.NewFromFloat(20200.0), currentPrice) + + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.True(t, stop.activated) + assert.Equal(t, fixedpoint.NewFromFloat(20200.0), stop.latestHigh) + } + + // 20200 + 1% = 20402 + currentPrice = currentPrice.Mul(one.Add(callbackRatio)) + assert.Equal(t, fixedpoint.NewFromFloat(20402.0), currentPrice) + + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.Equal(t, fixedpoint.NewFromFloat(20402.0), stop.latestHigh) + assert.True(t, stop.activated) + } + + // 20402 - 1% + currentPrice = currentPrice.Mul(one.Sub(callbackRatio)) + assert.Equal(t, fixedpoint.NewFromFloat(20197.98), currentPrice) + + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.Equal(t, fixedpoint.Zero, stop.latestHigh) + assert.False(t, stop.activated) + } +} diff --git a/pkg/bbgo/graceful_shutdown.go b/pkg/bbgo/graceful_shutdown.go index b35482ce2..c3248b0c8 100644 --- a/pkg/bbgo/graceful_shutdown.go +++ b/pkg/bbgo/graceful_shutdown.go @@ -3,18 +3,40 @@ package bbgo import ( "context" "sync" + "time" + + "github.com/sirupsen/logrus" ) +var graceful = &Graceful{} + //go:generate callbackgen -type Graceful type Graceful struct { shutdownCallbacks []func(ctx context.Context, wg *sync.WaitGroup) } +// Shutdown is a blocking call to emit all shutdown callbacks at the same time. func (g *Graceful) Shutdown(ctx context.Context) { var wg sync.WaitGroup wg.Add(len(g.shutdownCallbacks)) - go g.EmitShutdown(ctx, &wg) + // for each shutdown callback, we give them 10 second + shtCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + + go g.EmitShutdown(shtCtx, &wg) wg.Wait() + cancel() +} + +func OnShutdown(f func(ctx context.Context, wg *sync.WaitGroup)) { + graceful.OnShutdown(f) +} + +func Shutdown() { + logrus.Infof("shutting down...") + + ctx, cancel := context.WithTimeout(context.TODO(), 30*time.Second) + graceful.Shutdown(ctx) + cancel() } diff --git a/pkg/bbgo/injection_test.go b/pkg/bbgo/injection_test.go deleted file mode 100644 index dd6370320..000000000 --- a/pkg/bbgo/injection_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package bbgo - -import ( - "reflect" - "testing" - "time" - - "github.com/stretchr/testify/assert" - - "github.com/c9s/bbgo/pkg/service" - "github.com/c9s/bbgo/pkg/types" -) - -func Test_injectField(t *testing.T) { - type TT struct { - TradeService *service.TradeService - } - - // only pointer object can be set. - var tt = &TT{} - - // get the value of the pointer, or it can not be set. - var rv = reflect.ValueOf(tt).Elem() - - _, ret := hasField(rv, "TradeService") - assert.True(t, ret) - - ts := &service.TradeService{} - - err := injectField(rv, "TradeService", ts, true) - assert.NoError(t, err) -} - -func Test_parseStructAndInject(t *testing.T) { - t.Run("skip nil", func(t *testing.T) { - ss := struct { - a int - Env *Environment - }{ - a: 1, - Env: nil, - } - err := parseStructAndInject(&ss, nil) - assert.NoError(t, err) - assert.Nil(t, ss.Env) - }) - t.Run("pointer", func(t *testing.T) { - ss := struct { - a int - Env *Environment - }{ - a: 1, - Env: nil, - } - err := parseStructAndInject(&ss, &Environment{}) - assert.NoError(t, err) - assert.NotNil(t, ss.Env) - }) - - t.Run("composition", func(t *testing.T) { - type TT struct { - *service.TradeService - } - ss := TT{} - err := parseStructAndInject(&ss, &service.TradeService{}) - assert.NoError(t, err) - assert.NotNil(t, ss.TradeService) - }) - - t.Run("struct", func(t *testing.T) { - ss := struct { - a int - Env Environment - }{ - a: 1, - } - err := parseStructAndInject(&ss, Environment{ - startTime: time.Now(), - }) - assert.NoError(t, err) - assert.NotEqual(t, time.Time{}, ss.Env.startTime) - }) - t.Run("interface/any", func(t *testing.T) { - ss := struct { - Any interface{} // anything - }{ - Any: nil, - } - err := parseStructAndInject(&ss, &Environment{ - startTime: time.Now(), - }) - assert.NoError(t, err) - assert.NotNil(t, ss.Any) - }) - t.Run("interface/stringer", func(t *testing.T) { - ss := struct { - Stringer types.Stringer // stringer interface - }{ - Stringer: nil, - } - err := parseStructAndInject(&ss, &types.Trade{}) - assert.NoError(t, err) - assert.NotNil(t, ss.Stringer) - }) -} diff --git a/pkg/bbgo/order_executor_general.go b/pkg/bbgo/order_executor_general.go index 540dce477..afc63cdb7 100644 --- a/pkg/bbgo/order_executor_general.go +++ b/pkg/bbgo/order_executor_general.go @@ -53,7 +53,8 @@ func (e *GeneralOrderExecutor) BindTradeStats(tradeStats *types.TradeStats) { if profit == nil { return } - tradeStats.Add(profit.Profit) + + tradeStats.Add(profit) }) } @@ -65,7 +66,7 @@ func (e *GeneralOrderExecutor) BindProfitStats(profitStats *types.ProfitStats) { } profitStats.AddProfit(*profit) - Notify(&profitStats) + Notify(profitStats) }) } @@ -86,9 +87,9 @@ func (e *GeneralOrderExecutor) Bind() { e.tradeCollector.BindStream(e.session.UserDataStream) } +// CancelOrders cancels the given order objects directly func (e *GeneralOrderExecutor) CancelOrders(ctx context.Context, orders ...types.Order) error { - err := e.session.Exchange.CancelOrders(ctx, orders...) - return err + return e.session.Exchange.CancelOrders(ctx, orders...) } func (e *GeneralOrderExecutor) SubmitOrders(ctx context.Context, submitOrders ...types.SubmitOrder) (types.OrderSlice, error) { @@ -108,8 +109,9 @@ func (e *GeneralOrderExecutor) SubmitOrders(ctx context.Context, submitOrders .. return createdOrders, err } -func (e *GeneralOrderExecutor) GracefulCancel(ctx context.Context) error { - if err := e.activeMakerOrders.GracefulCancel(ctx, e.session.Exchange); err != nil { +// GracefulCancelActiveOrderBook cancels the orders from the active orderbook. +func (e *GeneralOrderExecutor) GracefulCancelActiveOrderBook(ctx context.Context, activeOrders *ActiveOrderBook) error { + if err := activeOrders.GracefulCancel(ctx, e.session.Exchange); err != nil { log.WithError(err).Errorf("graceful cancel order error") return err } @@ -118,6 +120,11 @@ func (e *GeneralOrderExecutor) GracefulCancel(ctx context.Context) error { return nil } +// GracefulCancel cancels all active maker orders +func (e *GeneralOrderExecutor) GracefulCancel(ctx context.Context) error { + return e.GracefulCancelActiveOrderBook(ctx, e.activeMakerOrders) +} + func (e *GeneralOrderExecutor) ClosePosition(ctx context.Context, percentage fixedpoint.Value, tags ...string) error { submitOrder := e.position.NewMarketCloseOrder(percentage) if submitOrder == nil { @@ -125,7 +132,6 @@ func (e *GeneralOrderExecutor) ClosePosition(ctx context.Context, percentage fix } submitOrder.Tag = strings.Join(tags, ",") - _, err := e.SubmitOrders(ctx, *submitOrder) return err } diff --git a/pkg/bbgo/persistence.go b/pkg/bbgo/persistence.go index b435c8f07..89b4179df 100644 --- a/pkg/bbgo/persistence.go +++ b/pkg/bbgo/persistence.go @@ -6,6 +6,7 @@ import ( log "github.com/sirupsen/logrus" + "github.com/c9s/bbgo/pkg/dynamic" "github.com/c9s/bbgo/pkg/service" ) @@ -106,10 +107,10 @@ func Sync(obj interface{}) { } func loadPersistenceFields(obj interface{}, id string, persistence service.PersistenceService) error { - return iterateFieldsByTag(obj, "persistence", func(tag string, field reflect.StructField, value reflect.Value) error { + return dynamic.IterateFieldsByTag(obj, "persistence", func(tag string, field reflect.StructField, value reflect.Value) error { log.Debugf("[loadPersistenceFields] loading value into field %v, tag = %s, original value = %v", field, tag, value) - newValueInf := newTypeValueInterface(value.Type()) + newValueInf := dynamic.NewTypeValueInterface(value.Type()) // inf := value.Interface() store := persistence.NewStore("state", id, tag) if err := store.Load(&newValueInf); err != nil { @@ -134,7 +135,7 @@ func loadPersistenceFields(obj interface{}, id string, persistence service.Persi } func storePersistenceFields(obj interface{}, id string, persistence service.PersistenceService) error { - return iterateFieldsByTag(obj, "persistence", func(tag string, ft reflect.StructField, fv reflect.Value) error { + return dynamic.IterateFieldsByTag(obj, "persistence", func(tag string, ft reflect.StructField, fv reflect.Value) error { log.Debugf("[storePersistenceFields] storing value from field %v, tag = %s, original value = %v", ft, tag, fv) inf := fv.Interface() diff --git a/pkg/bbgo/persistence_test.go b/pkg/bbgo/persistence_test.go index ebc5314f0..0eea57ed5 100644 --- a/pkg/bbgo/persistence_test.go +++ b/pkg/bbgo/persistence_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/c9s/bbgo/pkg/dynamic" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/service" "github.com/c9s/bbgo/pkg/types" @@ -23,7 +24,6 @@ func (s *TestStructWithoutInstanceID) ID() string { type TestStruct struct { *Environment - *Graceful Position *types.Position `persistence:"position"` Integer int64 `persistence:"integer"` @@ -83,7 +83,7 @@ func Test_loadPersistenceFields(t *testing.T) { t.Run(psName+"/nil", func(t *testing.T) { var b *TestStruct = nil err := loadPersistenceFields(b, "test-nil", ps) - assert.Equal(t, errCanNotIterateNilPointer, err) + assert.Equal(t, dynamic.ErrCanNotIterateNilPointer, err) }) t.Run(psName+"/pointer-field", func(t *testing.T) { diff --git a/pkg/bbgo/reflect.go b/pkg/bbgo/reflect.go index c9cb5a33b..263c2cc87 100644 --- a/pkg/bbgo/reflect.go +++ b/pkg/bbgo/reflect.go @@ -1,9 +1,9 @@ package bbgo import ( - "errors" - "fmt" "reflect" + + "github.com/c9s/bbgo/pkg/dynamic" ) type InstanceIDProvider interface { @@ -19,7 +19,7 @@ func callID(obj interface{}) string { return ret[0].String() } - if symbol, ok := isSymbolBasedStrategy(sv); ok { + if symbol, ok := dynamic.LookupSymbolField(sv); ok { m := sv.MethodByName("ID") ret := m.Call(nil) return ret[0].String() + ":" + symbol @@ -31,82 +31,3 @@ func callID(obj interface{}) string { return ret[0].String() + ":" } -func isSymbolBasedStrategy(rs reflect.Value) (string, bool) { - if rs.Kind() == reflect.Ptr { - rs = rs.Elem() - } - - field := rs.FieldByName("Symbol") - if !field.IsValid() { - return "", false - } - - if field.Kind() != reflect.String { - return "", false - } - - return field.String(), true -} - -func hasField(rs reflect.Value, fieldName string) (field reflect.Value, ok bool) { - field = rs.FieldByName(fieldName) - return field, field.IsValid() -} - -type StructFieldIterator func(tag string, ft reflect.StructField, fv reflect.Value) error - -var errCanNotIterateNilPointer = errors.New("can not iterate struct on a nil pointer") - -func iterateFieldsByTag(obj interface{}, tagName string, cb StructFieldIterator) error { - sv := reflect.ValueOf(obj) - st := reflect.TypeOf(obj) - - if st.Kind() != reflect.Ptr { - return fmt.Errorf("f should be a pointer of a struct, %s given", st) - } - - // for pointer, check if it's nil - if sv.IsNil() { - return errCanNotIterateNilPointer - } - - // solve the reference - st = st.Elem() - sv = sv.Elem() - - if st.Kind() != reflect.Struct { - return fmt.Errorf("f should be a struct, %s given", st) - } - - for i := 0; i < sv.NumField(); i++ { - fv := sv.Field(i) - ft := st.Field(i) - - // skip unexported fields - if !st.Field(i).IsExported() { - continue - } - - tag, ok := ft.Tag.Lookup(tagName) - if !ok { - continue - } - - if err := cb(tag, ft, fv); err != nil { - return err - } - } - - return nil -} - -// https://github.com/xiaojun207/go-base-utils/blob/master/utils/Clone.go -func newTypeValueInterface(typ reflect.Type) interface{} { - if typ.Kind() == reflect.Ptr { - typ = typ.Elem() - dst := reflect.New(typ).Elem() - return dst.Addr().Interface() - } - dst := reflect.New(typ) - return dst.Interface() -} diff --git a/pkg/bbgo/reflect_test.go b/pkg/bbgo/reflect_test.go new file mode 100644 index 000000000..920078f66 --- /dev/null +++ b/pkg/bbgo/reflect_test.go @@ -0,0 +1,2 @@ +package bbgo + diff --git a/pkg/bbgo/session.go b/pkg/bbgo/session.go index c6796c8f1..87bea4fe1 100644 --- a/pkg/bbgo/session.go +++ b/pkg/bbgo/session.go @@ -160,10 +160,6 @@ func (set *StandardIndicatorSet) VOLATILITY(iw types.IntervalWindow) *indicator. // ExchangeSession presents the exchange connection Session // It also maintains and collects the data returned from the stream. type ExchangeSession struct { - // exchange Session based notification system - // we make it as a value field so that we can configure it separately - Notifiability `json:"-" yaml:"-"` - // --------------------------- // Session config fields // --------------------------- @@ -253,12 +249,6 @@ func NewExchangeSession(name string, exchange types.Exchange) *ExchangeSession { marketDataStream.SetPublicOnly() session := &ExchangeSession{ - Notifiability: Notifiability{ - SymbolChannelRouter: NewPatternChannelRouter(nil), - SessionChannelRouter: NewPatternChannelRouter(nil), - ObjectChannelRouter: NewObjectChannelRouter(), - }, - Name: name, Exchange: exchange, UserDataStream: userDataStream, @@ -282,8 +272,7 @@ func NewExchangeSession(name string, exchange types.Exchange) *ExchangeSession { session.OrderExecutor = &ExchangeOrderExecutor{ // copy the notification system so that we can route - Notifiability: session.Notifiability, - Session: session, + Session: session, } return session @@ -805,11 +794,6 @@ func (session *ExchangeSession) InitExchange(name string, ex types.Exchange) err } session.Name = name - session.Notifiability = Notifiability{ - SymbolChannelRouter: NewPatternChannelRouter(nil), - SessionChannelRouter: NewPatternChannelRouter(nil), - ObjectChannelRouter: NewObjectChannelRouter(), - } session.Exchange = ex session.UserDataStream = ex.NewStream() session.MarketDataStream = ex.NewStream() @@ -830,8 +814,7 @@ func (session *ExchangeSession) InitExchange(name string, ex types.Exchange) err session.orderStores = make(map[string]*OrderStore) session.OrderExecutor = &ExchangeOrderExecutor{ // copy the notification system so that we can route - Notifiability: session.Notifiability, - Session: session, + Session: session, } session.usedSymbols = make(map[string]struct{}) diff --git a/pkg/bbgo/trader.go b/pkg/bbgo/trader.go index 4fc35e08e..e8cbc13ed 100644 --- a/pkg/bbgo/trader.go +++ b/pkg/bbgo/trader.go @@ -10,6 +10,7 @@ import ( _ "github.com/go-sql-driver/mysql" + "github.com/c9s/bbgo/pkg/dynamic" "github.com/c9s/bbgo/pkg/interact" ) @@ -72,8 +73,6 @@ type Trader struct { exchangeStrategies map[string][]SingleExchangeStrategy logger Logger - - Graceful Graceful } func NewTrader(environ *Environment) *Trader { @@ -197,11 +196,11 @@ func (trader *Trader) RunSingleExchangeStrategy(ctx context.Context, strategy Si return err } - if err := injectField(rs, "OrderExecutor", orderExecutor, false); err != nil { + if err := dynamic.InjectField(rs, "OrderExecutor", orderExecutor, false); err != nil { return errors.Wrapf(err, "failed to inject OrderExecutor on %T", strategy) } - if symbol, ok := isSymbolBasedStrategy(rs); ok { + if symbol, ok := dynamic.LookupSymbolField(rs); ok { log.Infof("found symbol based strategy from %s", rs.Type()) market, ok := session.Market(symbol) @@ -219,7 +218,7 @@ func (trader *Trader) RunSingleExchangeStrategy(ctx context.Context, strategy Si return fmt.Errorf("marketDataStore of symbol %s not found", symbol) } - if err := parseStructAndInject(strategy, + if err := dynamic.ParseStructAndInject(strategy, market, indicatorSet, store, @@ -394,7 +393,7 @@ func (trader *Trader) injectCommonServices(s interface{}) error { // a special injection for persistence selector: // if user defined the selector, the facade pointer will be nil, hence we need to update the persistence facade pointer sv := reflect.ValueOf(s).Elem() - if field, ok := hasField(sv, "Persistence"); ok { + if field, ok := dynamic.HasField(sv, "Persistence"); ok { // the selector is set, but we need to update the facade pointer if !field.IsNil() { elem := field.Elem() @@ -402,20 +401,19 @@ func (trader *Trader) injectCommonServices(s interface{}) error { return fmt.Errorf("field Persistence is not a struct element, %s given", field) } - if err := injectField(elem, "Facade", PersistenceServiceFacade, true); err != nil { + if err := dynamic.InjectField(elem, "Facade", PersistenceServiceFacade, true); err != nil { return err } /* - if err := parseStructAndInject(field.Interface(), persistenceFacade); err != nil { + if err := ParseStructAndInject(field.Interface(), persistenceFacade); err != nil { return err } */ } } - return parseStructAndInject(s, - &trader.Graceful, + return dynamic.ParseStructAndInject(s, &trader.logger, Notification, trader.environment.TradeService, diff --git a/pkg/cmd/backtest.go b/pkg/cmd/backtest.go index 363d2d282..18d190369 100644 --- a/pkg/cmd/backtest.go +++ b/pkg/cmd/backtest.go @@ -267,6 +267,13 @@ var BacktestCmd = &cobra.Command{ return err } + for _, session := range environ.Sessions() { + userDataStream := session.UserDataStream.(types.StandardStreamEmitter) + backtestEx := session.Exchange.(*backtest.Exchange) + backtestEx.MarketDataStream = session.MarketDataStream.(types.StandardStreamEmitter) + backtestEx.BindUserData(userDataStream) + } + trader := bbgo.NewTrader(environ) if verboseCnt == 0 { trader.DisableLogging() @@ -281,7 +288,7 @@ var BacktestCmd = &cobra.Command{ } backTestIntervals := []types.Interval{types.Interval1h, types.Interval1d} - exchangeSources, err := toExchangeSources(environ.Sessions(), backTestIntervals...) + exchangeSources, err := toExchangeSources(environ.Sessions(), startTime, endTime, backTestIntervals...) if err != nil { return err } @@ -443,9 +450,7 @@ var BacktestCmd = &cobra.Command{ cmdutil.WaitForSignal(runCtx, syscall.SIGINT, syscall.SIGTERM) log.Infof("shutting down trader...") - shutdownCtx, cancelShutdown := context.WithDeadline(runCtx, time.Now().Add(10*time.Second)) - trader.Graceful.Shutdown(shutdownCtx) - cancelShutdown() + bbgo.Shutdown() // put the logger back to print the pnl log.SetLevel(log.InfoLevel) @@ -642,14 +647,11 @@ func confirmation(s string) bool { } } -func toExchangeSources(sessions map[string]*bbgo.ExchangeSession, extraIntervals ...types.Interval) (exchangeSources []backtest.ExchangeDataSource, err error) { +func toExchangeSources(sessions map[string]*bbgo.ExchangeSession, startTime, endTime time.Time, extraIntervals ...types.Interval) (exchangeSources []backtest.ExchangeDataSource, err error) { for _, session := range sessions { - exchange := session.Exchange.(*backtest.Exchange) - exchange.UserDataStream = session.UserDataStream.(types.StandardStreamEmitter) - exchange.MarketDataStream = session.MarketDataStream.(types.StandardStreamEmitter) - exchange.InitMarketData() + backtestEx := session.Exchange.(*backtest.Exchange) - c, err := exchange.SubscribeMarketData(extraIntervals...) + c, err := backtestEx.SubscribeMarketData(startTime, endTime, extraIntervals...) if err != nil { return exchangeSources, err } @@ -657,7 +659,7 @@ func toExchangeSources(sessions map[string]*bbgo.ExchangeSession, extraIntervals sessionCopy := session exchangeSources = append(exchangeSources, backtest.ExchangeDataSource{ C: c, - Exchange: exchange, + Exchange: backtestEx, Session: sessionCopy, }) } diff --git a/pkg/cmd/optimize.go b/pkg/cmd/optimize.go index 27ed89f55..32bb2faab 100644 --- a/pkg/cmd/optimize.go +++ b/pkg/cmd/optimize.go @@ -4,12 +4,16 @@ import ( "context" "encoding/json" "fmt" + "io" "io/ioutil" "os" + "strconv" "github.com/spf13/cobra" "gopkg.in/yaml.v3" + "github.com/c9s/bbgo/pkg/data/tsv" + "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/optimizer" ) @@ -17,6 +21,7 @@ func init() { optimizeCmd.Flags().String("optimizer-config", "optimizer.yaml", "config file") optimizeCmd.Flags().String("output", "output", "backtest report output directory") optimizeCmd.Flags().Bool("json", false, "print optimizer metrics in json format") + optimizeCmd.Flags().Bool("tsv", false, "print optimizer metrics in csv format") RootCmd.AddCommand(optimizeCmd) } @@ -43,6 +48,11 @@ var optimizeCmd = &cobra.Command{ return err } + printTsvFormat, err := cmd.Flags().GetBool("tsv") + if err != nil { + return err + } + outputDirectory, err := cmd.Flags().GetString("output") if err != nil { return err @@ -104,6 +114,10 @@ var optimizeCmd = &cobra.Command{ // print metrics JSON to stdout fmt.Println(string(out)) + } else if printTsvFormat { + if err := formatMetricsTsv(metrics, os.Stdout); err != nil { + return err + } } else { for n, values := range metrics { if len(values) == 0 { @@ -120,3 +134,95 @@ var optimizeCmd = &cobra.Command{ return nil }, } + +func transformMetricsToRows(metrics map[string][]optimizer.Metric) (headers []string, rows [][]interface{}) { + var metricsKeys []string + for k := range metrics { + metricsKeys = append(metricsKeys, k) + } + + var numEntries int + var paramLabels []string + for _, ms := range metrics { + for _, m := range ms { + paramLabels = m.Labels + break + } + + numEntries = len(ms) + break + } + + headers = append(paramLabels, metricsKeys...) + rows = make([][]interface{}, numEntries) + + var metricsRows = make([][]interface{}, numEntries) + + // build params into the rows + for i, m := range metrics[metricsKeys[0]] { + rows[i] = m.Params + } + + for _, metricKey := range metricsKeys { + for i, ms := range metrics[metricKey] { + if len(metricsRows[i]) == 0 { + metricsRows[i] = make([]interface{}, 0, len(metricsKeys)) + } + metricsRows[i] = append(metricsRows[i], ms.Value) + } + } + + // merge rows + for i := range rows { + rows[i] = append(rows[i], metricsRows[i]...) + } + + return headers, rows +} + +func formatMetricsTsv(metrics map[string][]optimizer.Metric, writer io.WriteCloser) error { + headers, rows := transformMetricsToRows(metrics) + w := tsv.NewWriter(writer) + if err := w.Write(headers); err != nil { + return err + } + + for _, row := range rows { + var cells []string + for _, o := range row { + cell, err := castCellValue(o) + if err != nil { + return err + } + cells = append(cells, cell) + } + + if err := w.Write(cells); err != nil { + return err + } + } + return w.Close() +} + +func castCellValue(a interface{}) (string, error) { + switch tv := a.(type) { + case fixedpoint.Value: + return tv.String(), nil + case float64: + return strconv.FormatFloat(tv, 'f', -1, 64), nil + case int64: + return strconv.FormatInt(tv, 10), nil + case int32: + return strconv.FormatInt(int64(tv), 10), nil + case int: + return strconv.Itoa(tv), nil + case bool: + return strconv.FormatBool(tv), nil + case string: + return tv, nil + case []byte: + return string(tv), nil + default: + return "", fmt.Errorf("unsupported object type: %T value: %v", tv, tv) + } +} diff --git a/pkg/cmd/pnl.go b/pkg/cmd/pnl.go index 3474b2b34..8d3fd4231 100644 --- a/pkg/cmd/pnl.go +++ b/pkg/cmd/pnl.go @@ -184,9 +184,15 @@ var PnLCmd = &cobra.Command{ return errors.New("no ticker data for current price") } + market, ok := session.Market(symbol) + if !ok { + return fmt.Errorf("market not found: %s, %s", symbol, session.Exchange.Name()) + } + currentPrice := currentTick.Last calculator := &pnl.AverageCostCalculator{ TradingFeeCurrency: tradingFeeCurrency, + Market: market, } report := calculator.Calculate(symbol, trades, currentPrice) diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index ca634c0a5..c6f1841d6 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -8,7 +8,6 @@ import ( "path/filepath" "runtime/pprof" "syscall" - "time" "github.com/pkg/errors" log "github.com/sirupsen/logrus" @@ -78,12 +77,7 @@ func runSetup(baseCtx context.Context, userConfig *bbgo.Config, enableApiServer cmdutil.WaitForSignal(ctx, syscall.SIGINT, syscall.SIGTERM) cancelTrading() - // graceful period = 15 second - shutdownCtx, cancelShutdown := context.WithDeadline(ctx, time.Now().Add(15*time.Second)) - - log.Infof("shutting down...") - trader.Graceful.Shutdown(shutdownCtx) - cancelShutdown() + bbgo.Shutdown() return nil } @@ -216,10 +210,7 @@ func runConfig(basectx context.Context, cmd *cobra.Command, userConfig *bbgo.Con cmdutil.WaitForSignal(ctx, syscall.SIGINT, syscall.SIGTERM) cancelTrading() - log.Infof("shutting down...") - shutdownCtx, cancelShutdown := context.WithDeadline(ctx, time.Now().Add(30*time.Second)) - trader.Graceful.Shutdown(shutdownCtx) - cancelShutdown() + bbgo.Shutdown() if err := trader.SaveState(); err != nil { log.WithError(err).Errorf("can not save strategy states") diff --git a/pkg/dynamic/call.go b/pkg/dynamic/call.go new file mode 100644 index 000000000..4ba59484d --- /dev/null +++ b/pkg/dynamic/call.go @@ -0,0 +1,155 @@ +package dynamic + +import ( + "errors" + "reflect" +) + +// CallStructFieldsMethod iterates field from the given struct object +// check if the field object implements the interface, if it's implemented, then we call a specific method +func CallStructFieldsMethod(m interface{}, method string, args ...interface{}) error { + rv := reflect.ValueOf(m) + rt := reflect.TypeOf(m) + + if rt.Kind() != reflect.Ptr { + return errors.New("the given object needs to be a pointer") + } + + rv = rv.Elem() + rt = rt.Elem() + + if rt.Kind() != reflect.Struct { + return errors.New("the given object needs to be struct") + } + + argValues := ToReflectValues(args...) + for i := 0; i < rt.NumField(); i++ { + fieldType := rt.Field(i) + fieldValue := rv.Field(i) + + // skip non-exported fields + if !fieldType.IsExported() { + continue + } + + if fieldType.Type.Kind() == reflect.Ptr && fieldValue.IsNil() { + continue + } + + methodType, ok := fieldType.Type.MethodByName(method) + if !ok { + continue + } + + if len(argValues) < methodType.Type.NumIn() { + // return fmt.Errorf("method %v require %d args, %d given", methodType, methodType.Type.NumIn(), len(argValues)) + } + + refMethod := fieldValue.MethodByName(method) + refMethod.Call(argValues) + } + + return nil +} + +// CallMatch calls the function with the matched argument automatically +// you can define multiple parameter factory function to inject the return value as the function argument. +// e.g., +// CallMatch(targetFunction, 1, 10, true, func() *ParamType { .... }) +// +func CallMatch(f interface{}, objects ...interface{}) ([]reflect.Value, error) { + fv := reflect.ValueOf(f) + ft := reflect.TypeOf(f) + + var startIndex = 0 + var fArgs []reflect.Value + + var factoryParams = findFactoryParams(objects...) + +nextDynamicInputArg: + for i := 0; i < ft.NumIn(); i++ { + at := ft.In(i) + + // uat == underlying argument type + uat := at + if at.Kind() == reflect.Ptr { + uat = at.Elem() + } + + for oi := startIndex; oi < len(objects); oi++ { + var obj = objects[oi] + var objT = reflect.TypeOf(obj) + if objT == at { + fArgs = append(fArgs, reflect.ValueOf(obj)) + startIndex = oi + 1 + continue nextDynamicInputArg + } + + // get the kind of argument + switch k := uat.Kind(); k { + + case reflect.Interface: + if objT.Implements(at) { + fArgs = append(fArgs, reflect.ValueOf(obj)) + startIndex = oi + 1 + continue nextDynamicInputArg + } + } + } + + // factory param can be reused + for _, fp := range factoryParams { + fpt := fp.Type() + outType := fpt.Out(0) + if outType == at { + fOut := fp.Call(nil) + fArgs = append(fArgs, fOut[0]) + continue nextDynamicInputArg + } + } + + fArgs = append(fArgs, reflect.Zero(at)) + } + + out := fv.Call(fArgs) + if ft.NumOut() == 0 { + return out, nil + } + + // try to get the error object from the return value (if any) + var err error + for i := 0; i < ft.NumOut(); i++ { + outType := ft.Out(i) + switch outType.Kind() { + case reflect.Interface: + o := out[i].Interface() + switch ov := o.(type) { + case error: + err = ov + + } + + } + } + return out, err +} + +func findFactoryParams(objs ...interface{}) (fs []reflect.Value) { + for i := range objs { + obj := objs[i] + + objT := reflect.TypeOf(obj) + + if objT.Kind() != reflect.Func { + continue + } + + if objT.NumOut() == 0 || objT.NumIn() > 0 { + continue + } + + fs = append(fs, reflect.ValueOf(obj)) + } + + return fs +} diff --git a/pkg/dynamic/call_test.go b/pkg/dynamic/call_test.go new file mode 100644 index 000000000..b65029ded --- /dev/null +++ b/pkg/dynamic/call_test.go @@ -0,0 +1,115 @@ +package dynamic + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type callTest struct { + ChildCall1 *childCall1 + ChildCall2 *childCall2 +} + +type childCall1 struct{} + +func (c *childCall1) Subscribe(a int) {} + +type childCall2 struct{} + +func (c *childCall2) Subscribe(a int) {} + +func TestCallStructFieldsMethod(t *testing.T) { + c := &callTest{ + ChildCall1: &childCall1{}, + ChildCall2: &childCall2{}, + } + err := CallStructFieldsMethod(c, "Subscribe", 10) + assert.NoError(t, err) +} + +type S struct { + ID string +} + +func (s *S) String() string { return s.ID } + +func TestCallMatch(t *testing.T) { + t.Run("simple", func(t *testing.T) { + f := func(a int, b int) { + assert.Equal(t, 1, a) + assert.Equal(t, 2, b) + } + _, err := CallMatch(f, 1, 2) + assert.NoError(t, err) + }) + + t.Run("interface", func(t *testing.T) { + type A interface { + String() string + } + f := func(foo int, a A) { + assert.Equal(t, "foo", a.String()) + } + _, err := CallMatch(f, 10, &S{ID: "foo"}) + assert.NoError(t, err) + }) + + t.Run("nil interface", func(t *testing.T) { + type A interface { + String() string + } + f := func(foo int, a A) { + assert.Equal(t, 10, foo) + assert.Nil(t, a) + } + _, err := CallMatch(f, 10) + assert.NoError(t, err) + }) + + t.Run("struct pointer", func(t *testing.T) { + f := func(foo int, s *S) { + assert.Equal(t, 10, foo) + assert.NotNil(t, s) + } + _, err := CallMatch(f, 10, &S{}) + assert.NoError(t, err) + }) + + t.Run("struct pointer x 2", func(t *testing.T) { + f := func(foo int, s1, s2 *S) { + assert.Equal(t, 10, foo) + assert.Equal(t, "s1", s1.String()) + assert.Equal(t, "s2", s2.String()) + } + _, err := CallMatch(f, 10, &S{ID: "s1"}, &S{ID: "s2"}) + assert.NoError(t, err) + }) + + t.Run("func factory", func(t *testing.T) { + f := func(s *S) { + assert.Equal(t, "factory", s.String()) + } + _, err := CallMatch(f, func() *S { + return &S{ID: "factory"} + }) + assert.NoError(t, err) + }) + + t.Run("nil", func(t *testing.T) { + f := func(s *S) { + assert.Nil(t, s) + } + _, err := CallMatch(f) + assert.NoError(t, err) + }) + + t.Run("zero struct", func(t *testing.T) { + f := func(s S) { + assert.Equal(t, S{}, s) + } + _, err := CallMatch(f) + assert.NoError(t, err) + }) + +} diff --git a/pkg/dynamic/field.go b/pkg/dynamic/field.go new file mode 100644 index 000000000..5fc222be1 --- /dev/null +++ b/pkg/dynamic/field.go @@ -0,0 +1,26 @@ +package dynamic + +import "reflect" + +func HasField(rs reflect.Value, fieldName string) (field reflect.Value, ok bool) { + field = rs.FieldByName(fieldName) + return field, field.IsValid() +} + +func LookupSymbolField(rs reflect.Value) (string, bool) { + if rs.Kind() == reflect.Ptr { + rs = rs.Elem() + } + + field := rs.FieldByName("Symbol") + if !field.IsValid() { + return "", false + } + + if field.Kind() != reflect.String { + return "", false + } + + return field.String(), true +} + diff --git a/pkg/bbgo/injection.go b/pkg/dynamic/inject.go similarity index 54% rename from pkg/bbgo/injection.go rename to pkg/dynamic/inject.go index 0db8cf228..04a48599b 100644 --- a/pkg/bbgo/injection.go +++ b/pkg/dynamic/inject.go @@ -1,13 +1,23 @@ -package bbgo +package dynamic import ( "fmt" "reflect" + "testing" + "time" "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/service" + "github.com/c9s/bbgo/pkg/types" ) -func injectField(rs reflect.Value, fieldName string, obj interface{}, pointerOnly bool) error { +type testEnvironment struct { + startTime time.Time +} + +func InjectField(rs reflect.Value, fieldName string, obj interface{}, pointerOnly bool) error { field := rs.FieldByName(fieldName) if !field.IsValid() { return nil @@ -38,10 +48,10 @@ func injectField(rs reflect.Value, fieldName string, obj interface{}, pointerOnl return nil } -// parseStructAndInject parses the struct fields and injects the objects into the corresponding fields by its type. +// ParseStructAndInject parses the struct fields and injects the objects into the corresponding fields by its type. // if the given object is a reference of an object, the type of the target field MUST BE a pointer field. // if the given object is a struct value, the type of the target field CAN BE a pointer field or a struct value field. -func parseStructAndInject(f interface{}, objects ...interface{}) error { +func ParseStructAndInject(f interface{}, objects ...interface{}) error { sv := reflect.ValueOf(f) st := reflect.TypeOf(f) @@ -121,3 +131,96 @@ func parseStructAndInject(f interface{}, objects ...interface{}) error { return nil } + +func Test_injectField(t *testing.T) { + type TT struct { + TradeService *service.TradeService + } + + // only pointer object can be set. + var tt = &TT{} + + // get the value of the pointer, or it can not be set. + var rv = reflect.ValueOf(tt).Elem() + + _, ret := HasField(rv, "TradeService") + assert.True(t, ret) + + ts := &service.TradeService{} + + err := InjectField(rv, "TradeService", ts, true) + assert.NoError(t, err) +} + +func Test_parseStructAndInject(t *testing.T) { + t.Run("skip nil", func(t *testing.T) { + ss := struct { + a int + Env *testEnvironment + }{ + a: 1, + Env: nil, + } + err := ParseStructAndInject(&ss, nil) + assert.NoError(t, err) + assert.Nil(t, ss.Env) + }) + t.Run("pointer", func(t *testing.T) { + ss := struct { + a int + Env *testEnvironment + }{ + a: 1, + Env: nil, + } + err := ParseStructAndInject(&ss, &testEnvironment{}) + assert.NoError(t, err) + assert.NotNil(t, ss.Env) + }) + + t.Run("composition", func(t *testing.T) { + type TT struct { + *service.TradeService + } + ss := TT{} + err := ParseStructAndInject(&ss, &service.TradeService{}) + assert.NoError(t, err) + assert.NotNil(t, ss.TradeService) + }) + + t.Run("struct", func(t *testing.T) { + ss := struct { + a int + Env testEnvironment + }{ + a: 1, + } + err := ParseStructAndInject(&ss, testEnvironment{ + startTime: time.Now(), + }) + assert.NoError(t, err) + assert.NotEqual(t, time.Time{}, ss.Env.startTime) + }) + t.Run("interface/any", func(t *testing.T) { + ss := struct { + Any interface{} // anything + }{ + Any: nil, + } + err := ParseStructAndInject(&ss, &testEnvironment{ + startTime: time.Now(), + }) + assert.NoError(t, err) + assert.NotNil(t, ss.Any) + }) + t.Run("interface/stringer", func(t *testing.T) { + ss := struct { + Stringer types.Stringer // stringer interface + }{ + Stringer: nil, + } + err := ParseStructAndInject(&ss, &types.Trade{}) + assert.NoError(t, err) + assert.NotNil(t, ss.Stringer) + }) +} diff --git a/pkg/dynamic/iterate.go b/pkg/dynamic/iterate.go new file mode 100644 index 000000000..765616392 --- /dev/null +++ b/pkg/dynamic/iterate.go @@ -0,0 +1,96 @@ +package dynamic + +import ( + "errors" + "fmt" + "reflect" +) + +type StructFieldIterator func(tag string, ft reflect.StructField, fv reflect.Value) error + +var ErrCanNotIterateNilPointer = errors.New("can not iterate struct on a nil pointer") + +func IterateFields(obj interface{}, cb func(ft reflect.StructField, fv reflect.Value) error) error { + if obj == nil { + return errors.New("can not iterate field, given object is nil") + } + + sv := reflect.ValueOf(obj) + st := reflect.TypeOf(obj) + + if st.Kind() != reflect.Ptr { + return fmt.Errorf("f should be a pointer of a struct, %s given", st) + } + + // for pointer, check if it's nil + if sv.IsNil() { + return ErrCanNotIterateNilPointer + } + + // solve the reference + st = st.Elem() + sv = sv.Elem() + + if st.Kind() != reflect.Struct { + return fmt.Errorf("f should be a struct, %s given", st) + } + + for i := 0; i < sv.NumField(); i++ { + fv := sv.Field(i) + ft := st.Field(i) + + // skip unexported fields + if !st.Field(i).IsExported() { + continue + } + + if err := cb(ft, fv) ; err != nil { + return err + } + } + + return nil +} + +func IterateFieldsByTag(obj interface{}, tagName string, cb StructFieldIterator) error { + sv := reflect.ValueOf(obj) + st := reflect.TypeOf(obj) + + if st.Kind() != reflect.Ptr { + return fmt.Errorf("f should be a pointer of a struct, %s given", st) + } + + // for pointer, check if it's nil + if sv.IsNil() { + return ErrCanNotIterateNilPointer + } + + // solve the reference + st = st.Elem() + sv = sv.Elem() + + if st.Kind() != reflect.Struct { + return fmt.Errorf("f should be a struct, %s given", st) + } + + for i := 0; i < sv.NumField(); i++ { + fv := sv.Field(i) + ft := st.Field(i) + + // skip unexported fields + if !st.Field(i).IsExported() { + continue + } + + tag, ok := ft.Tag.Lookup(tagName) + if !ok { + continue + } + + if err := cb(tag, ft, fv); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/dynamic/iterate_test.go b/pkg/dynamic/iterate_test.go new file mode 100644 index 000000000..b15b74bf8 --- /dev/null +++ b/pkg/dynamic/iterate_test.go @@ -0,0 +1,44 @@ +package dynamic + +import ( + "os" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIterateFields(t *testing.T) { + + t.Run("basic", func(t *testing.T) { + var a = struct { + A int + B float64 + C *os.File + }{} + + cnt := 0 + err := IterateFields(&a, func(ft reflect.StructField, fv reflect.Value) error { + cnt++ + return nil + }) + assert.NoError(t, err) + assert.Equal(t, 3, cnt) + }) + + t.Run("non-ptr", func(t *testing.T) { + err := IterateFields(struct{}{}, func(ft reflect.StructField, fv reflect.Value) error { + return nil + }) + assert.Error(t, err) + }) + + t.Run("nil", func(t *testing.T) { + err := IterateFields(nil, func(ft reflect.StructField, fv reflect.Value) error { + return nil + }) + assert.Error(t, err) + }) + + +} diff --git a/pkg/dynamic/merge.go b/pkg/dynamic/merge.go new file mode 100644 index 000000000..8e44bd333 --- /dev/null +++ b/pkg/dynamic/merge.go @@ -0,0 +1,41 @@ +package dynamic + +import "reflect" + +// InheritStructValues merges the field value from the source struct to the dest struct. +// Only fields with the same type and the same name will be updated. +func InheritStructValues(dst, src interface{}) { + if dst == nil { + return + } + + rtA := reflect.TypeOf(dst) + srcStructType := reflect.TypeOf(src) + + rtA = rtA.Elem() + srcStructType = srcStructType.Elem() + + for i := 0; i < rtA.NumField(); i++ { + fieldType := rtA.Field(i) + fieldName := fieldType.Name + + if !fieldType.IsExported() { + continue + } + + // if there is a field with the same name + fieldSrcType, found := srcStructType.FieldByName(fieldName) + if !found { + continue + } + + // ensure that the type is the same + if fieldSrcType.Type == fieldType.Type { + srcValue := reflect.ValueOf(src).Elem().FieldByName(fieldName) + dstValue := reflect.ValueOf(dst).Elem().FieldByName(fieldName) + if (fieldType.Type.Kind() == reflect.Ptr && dstValue.IsNil()) || dstValue.IsZero() { + dstValue.Set(srcValue) + } + } + } +} diff --git a/pkg/dynamic/merge_test.go b/pkg/dynamic/merge_test.go new file mode 100644 index 000000000..ca61355b0 --- /dev/null +++ b/pkg/dynamic/merge_test.go @@ -0,0 +1,82 @@ +package dynamic + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +type TestStrategy struct { + Symbol string `json:"symbol"` + Interval string `json:"interval"` + BaseQuantity fixedpoint.Value `json:"baseQuantity"` + MaxAssetQuantity fixedpoint.Value `json:"maxAssetQuantity"` + MinDropPercentage fixedpoint.Value `json:"minDropPercentage"` +} + +func Test_reflectMergeStructFields(t *testing.T) { + t.Run("zero value", func(t *testing.T) { + a := &TestStrategy{Symbol: "BTCUSDT"} + b := &struct{ Symbol string }{Symbol: ""} + InheritStructValues(b, a) + assert.Equal(t, "BTCUSDT", b.Symbol) + }) + + t.Run("non-zero value", func(t *testing.T) { + a := &TestStrategy{Symbol: "BTCUSDT"} + b := &struct{ Symbol string }{Symbol: "ETHUSDT"} + InheritStructValues(b, a) + assert.Equal(t, "ETHUSDT", b.Symbol, "should be the original value") + }) + + t.Run("zero embedded struct", func(t *testing.T) { + iw := types.IntervalWindow{Interval: types.Interval1h, Window: 30} + a := &struct { + types.IntervalWindow + Symbol string + }{ + IntervalWindow: iw, + Symbol: "BTCUSDT", + } + b := &struct { + Symbol string + types.IntervalWindow + }{} + InheritStructValues(b, a) + assert.Equal(t, iw, b.IntervalWindow) + assert.Equal(t, "BTCUSDT", b.Symbol) + }) + + t.Run("non-zero embedded struct", func(t *testing.T) { + iw := types.IntervalWindow{Interval: types.Interval1h, Window: 30} + a := &struct { + types.IntervalWindow + }{ + IntervalWindow: iw, + } + b := &struct { + types.IntervalWindow + }{ + IntervalWindow: types.IntervalWindow{Interval: types.Interval5m, Window: 9}, + } + InheritStructValues(b, a) + assert.Equal(t, types.IntervalWindow{Interval: types.Interval5m, Window: 9}, b.IntervalWindow) + }) + + t.Run("skip different type but the same name", func(t *testing.T) { + a := &struct { + A float64 + }{ + A: 1.99, + } + b := &struct { + A string + }{} + InheritStructValues(b, a) + assert.Equal(t, "", b.A) + assert.Equal(t, 1.99, a.A) + }) +} diff --git a/pkg/dynamic/typevalue.go b/pkg/dynamic/typevalue.go new file mode 100644 index 000000000..3ca3f1c83 --- /dev/null +++ b/pkg/dynamic/typevalue.go @@ -0,0 +1,24 @@ +package dynamic + +import "reflect" + +// https://github.com/xiaojun207/go-base-utils/blob/master/utils/Clone.go +func NewTypeValueInterface(typ reflect.Type) interface{} { + if typ.Kind() == reflect.Ptr { + typ = typ.Elem() + dst := reflect.New(typ).Elem() + return dst.Addr().Interface() + } + dst := reflect.New(typ) + return dst.Interface() +} + +// ToReflectValues convert the go objects into reflect.Value slice +func ToReflectValues(args ...interface{}) (values []reflect.Value) { + for i := range args { + arg := args[i] + values = append(values, reflect.ValueOf(arg)) + } + + return values +} diff --git a/pkg/indicator/ad.go b/pkg/indicator/ad.go index d7263a5ab..6cdf0e3d9 100644 --- a/pkg/indicator/ad.go +++ b/pkg/indicator/ad.go @@ -14,6 +14,7 @@ Accumulation/Distribution Indicator (A/D) */ //go:generate callbackgen -type AD type AD struct { + types.SeriesBase types.IntervalWindow Values types.Float64Slice PrePrice float64 @@ -23,6 +24,9 @@ type AD struct { } func (inc *AD) Update(high, low, cloze, volume float64) { + if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc + } var moneyFlowVolume float64 if high == low { moneyFlowVolume = 0 @@ -53,7 +57,7 @@ func (inc *AD) Length() int { return len(inc.Values) } -var _ types.Series = &AD{} +var _ types.SeriesExtend = &AD{} func (inc *AD) calculateAndUpdate(kLines []types.KLine) { for _, k := range kLines { diff --git a/pkg/indicator/alma.go b/pkg/indicator/alma.go index 44e0f18c7..d9a03ad0a 100644 --- a/pkg/indicator/alma.go +++ b/pkg/indicator/alma.go @@ -13,11 +13,12 @@ import ( // @param sigma: the standard deviation applied to the combo line. This makes the combo line sharper //go:generate callbackgen -type ALMA type ALMA struct { + types.SeriesBase types.IntervalWindow // required Offset float64 // required: recommend to be 5 Sigma int // required: recommend to be 0.5 - Weight []float64 - Sum float64 + weight []float64 + sum float64 input []float64 Values types.Float64Slice UpdateCallbacks []func(value float64) @@ -27,16 +28,17 @@ const MaxNumOfALMA = 5_000 const MaxNumOfALMATruncateSize = 100 func (inc *ALMA) Update(value float64) { - if inc.Weight == nil { - inc.Weight = make([]float64, inc.Window) + if inc.weight == nil { + inc.SeriesBase.Series = inc + inc.weight = make([]float64, inc.Window) m := inc.Offset * (float64(inc.Window) - 1.) s := float64(inc.Window) / float64(inc.Sigma) - inc.Sum = 0. + inc.sum = 0. for i := 0; i < inc.Window; i++ { diff := float64(i) - m wt := math.Exp(-diff * diff / 2. / s / s) - inc.Sum += wt - inc.Weight[i] = wt + inc.sum += wt + inc.weight[i] = wt } } inc.input = append(inc.input, value) @@ -44,9 +46,9 @@ func (inc *ALMA) Update(value float64) { weightedSum := 0.0 inc.input = inc.input[len(inc.input)-inc.Window:] for i := 0; i < inc.Window; i++ { - weightedSum += inc.Weight[inc.Window-i-1] * inc.input[i] + weightedSum += inc.weight[inc.Window-i-1] * inc.input[i] } - inc.Values.Push(weightedSum / inc.Sum) + inc.Values.Push(weightedSum / inc.sum) if len(inc.Values) > MaxNumOfALMA { inc.Values = inc.Values[MaxNumOfALMATruncateSize-1:] } @@ -71,6 +73,8 @@ func (inc *ALMA) Length() int { return len(inc.Values) } +var _ types.SeriesExtend = &ALMA{} + func (inc *ALMA) calculateAndUpdate(allKLines []types.KLine) { if inc.input == nil { for _, k := range allKLines { diff --git a/pkg/indicator/atr.go b/pkg/indicator/atr.go index 327a30263..759cffebe 100644 --- a/pkg/indicator/atr.go +++ b/pkg/indicator/atr.go @@ -9,6 +9,7 @@ import ( //go:generate callbackgen -type ATR type ATR struct { + types.SeriesBase types.IntervalWindow PercentageVolatility types.Float64Slice @@ -25,6 +26,7 @@ func (inc *ATR) Update(high, low, cloze float64) { } if inc.RMA == nil { + inc.SeriesBase.Series = inc inc.RMA = &RMA{ IntervalWindow: types.IntervalWindow{Window: inc.Window}, Adjust: true, @@ -73,7 +75,7 @@ func (inc *ATR) Length() int { return inc.RMA.Length() } -var _ types.Series = &ATR{} +var _ types.SeriesExtend = &ATR{} func (inc *ATR) CalculateAndUpdate(kLines []types.KLine) { for _, k := range kLines { diff --git a/pkg/indicator/boll.go b/pkg/indicator/boll.go index 70338be04..30ebf8466 100644 --- a/pkg/indicator/boll.go +++ b/pkg/indicator/boll.go @@ -41,20 +41,20 @@ type BOLL struct { type BandType int -func (inc *BOLL) GetUpBand() types.Series { - return &inc.UpBand +func (inc *BOLL) GetUpBand() types.SeriesExtend { + return types.NewSeries(&inc.UpBand) } -func (inc *BOLL) GetDownBand() types.Series { - return &inc.DownBand +func (inc *BOLL) GetDownBand() types.SeriesExtend { + return types.NewSeries(&inc.DownBand) } -func (inc *BOLL) GetSMA() types.Series { - return &inc.SMA +func (inc *BOLL) GetSMA() types.SeriesExtend { + return types.NewSeries(&inc.SMA) } -func (inc *BOLL) GetStdDev() types.Series { - return &inc.StdDev +func (inc *BOLL) GetStdDev() types.SeriesExtend { + return types.NewSeries(&inc.StdDev) } func (inc *BOLL) LastUpBand() float64 { diff --git a/pkg/indicator/cci.go b/pkg/indicator/cci.go index 9380ad816..ef0ec558d 100644 --- a/pkg/indicator/cci.go +++ b/pkg/indicator/cci.go @@ -12,6 +12,7 @@ import ( // with modification of ddof=0 to let standard deviation to be divided by N instead of N-1 //go:generate callbackgen -type CCI type CCI struct { + types.SeriesBase types.IntervalWindow Input types.Float64Slice TypicalPrice types.Float64Slice @@ -23,6 +24,7 @@ type CCI struct { func (inc *CCI) Update(value float64) { if len(inc.TypicalPrice) == 0 { + inc.SeriesBase.Series = inc inc.TypicalPrice.Push(value) inc.Input.Push(value) return @@ -75,7 +77,7 @@ func (inc *CCI) Length() int { return len(inc.Values) } -var _ types.Series = &CCI{} +var _ types.SeriesExtend = &CCI{} var three = fixedpoint.NewFromInt(3) diff --git a/pkg/indicator/cma.go b/pkg/indicator/cma.go index 8040c8707..fbdf35734 100644 --- a/pkg/indicator/cma.go +++ b/pkg/indicator/cma.go @@ -8,6 +8,7 @@ import ( // Refer: https://en.wikipedia.org/wiki/Moving_average //go:generate callbackgen -type CA type CA struct { + types.SeriesBase Interval types.Interval Values types.Float64Slice length float64 @@ -15,11 +16,15 @@ type CA struct { } func (inc *CA) Update(x float64) { + if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc + } newVal := (inc.Values.Last()*inc.length + x) / (inc.length + 1.) inc.length += 1 inc.Values.Push(newVal) if len(inc.Values) > MaxNumOfEWMA { inc.Values = inc.Values[MaxNumOfEWMATruncateSize-1:] + inc.length = float64(len(inc.Values)) } } @@ -41,7 +46,7 @@ func (inc *CA) Length() int { return len(inc.Values) } -var _ types.Series = &CA{} +var _ types.SeriesExtend = &CA{} func (inc *CA) calculateAndUpdate(allKLines []types.KLine) { for _, k := range allKLines { diff --git a/pkg/indicator/dema.go b/pkg/indicator/dema.go index bc476134a..d9152d279 100644 --- a/pkg/indicator/dema.go +++ b/pkg/indicator/dema.go @@ -10,6 +10,7 @@ import ( //go:generate callbackgen -type DEMA type DEMA struct { types.IntervalWindow + types.SeriesBase Values types.Float64Slice a1 *EWMA a2 *EWMA @@ -19,6 +20,7 @@ type DEMA struct { func (inc *DEMA) Update(value float64) { if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc inc.a1 = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, inc.Window}} inc.a2 = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, inc.Window}} } @@ -46,7 +48,7 @@ func (inc *DEMA) Length() int { return len(inc.Values) } -var _ types.Series = &DEMA{} +var _ types.SeriesExtend = &DEMA{} func (inc *DEMA) calculateAndUpdate(allKLines []types.KLine) { if inc.a1 == nil { diff --git a/pkg/indicator/dmi.go b/pkg/indicator/dmi.go index cb0fc7169..74ea75eaa 100644 --- a/pkg/indicator/dmi.go +++ b/pkg/indicator/dmi.go @@ -17,11 +17,11 @@ type DMI struct { types.IntervalWindow ADXSmoothing int atr *ATR - DMP types.UpdatableSeries - DMN types.UpdatableSeries + DMP types.UpdatableSeriesExtend + DMN types.UpdatableSeriesExtend DIPlus *types.Queue DIMinus *types.Queue - ADX types.UpdatableSeries + ADX types.UpdatableSeriesExtend PrevHigh, PrevLow float64 UpdateCallbacks []func(diplus, diminus, adx float64) } @@ -71,15 +71,15 @@ func (inc *DMI) Update(high, low, cloze float64) { } -func (inc *DMI) GetDIPlus() types.Series { +func (inc *DMI) GetDIPlus() types.SeriesExtend { return inc.DIPlus } -func (inc *DMI) GetDIMinus() types.Series { +func (inc *DMI) GetDIMinus() types.SeriesExtend { return inc.DIMinus } -func (inc *DMI) GetADX() types.Series { +func (inc *DMI) GetADX() types.SeriesExtend { return inc.ADX } diff --git a/pkg/indicator/drift.go b/pkg/indicator/drift.go index bda5b51d5..8494acc39 100644 --- a/pkg/indicator/drift.go +++ b/pkg/indicator/drift.go @@ -11,6 +11,7 @@ import ( // could be used in Monte Carlo Simulations //go:generate callbackgen -type Drift type Drift struct { + types.SeriesBase types.IntervalWindow chng *types.Queue Values types.Float64Slice @@ -22,6 +23,7 @@ type Drift struct { func (inc *Drift) Update(value float64) { if inc.chng == nil { + inc.SeriesBase.Series = inc inc.SMA = &SMA{IntervalWindow: types.IntervalWindow{Interval: inc.Interval, Window: inc.Window}} inc.chng = types.NewQueue(inc.Window) inc.LastValue = value @@ -64,7 +66,7 @@ func (inc *Drift) Length() int { return inc.Values.Length() } -var _ types.Series = &Drift{} +var _ types.SeriesExtend = &Drift{} func (inc *Drift) calculateAndUpdate(allKLines []types.KLine) { if inc.chng == nil { diff --git a/pkg/indicator/emv.go b/pkg/indicator/emv.go index 08d439e45..f626ad70e 100644 --- a/pkg/indicator/emv.go +++ b/pkg/indicator/emv.go @@ -9,6 +9,7 @@ import ( //go:generate callbackgen -type EMV type EMV struct { + types.SeriesBase types.IntervalWindow prevH float64 prevL float64 @@ -25,6 +26,7 @@ func (inc *EMV) Update(high, low, vol float64) { inc.EMVScale = DefaultEMVScale } if inc.prevH == 0 || inc.Values == nil { + inc.SeriesBase.Series = inc inc.prevH = high inc.prevL = low inc.Values = &SMA{IntervalWindow: inc.IntervalWindow} @@ -59,7 +61,7 @@ func (inc *EMV) Length() int { return inc.Values.Length() } -var _ types.Series = &EMV{} +var _ types.SeriesExtend = &EMV{} func (inc *EMV) calculateAndUpdate(allKLines []types.KLine) { if inc.Values == nil { diff --git a/pkg/indicator/ewma.go b/pkg/indicator/ewma.go index d94fb7953..4335c1316 100644 --- a/pkg/indicator/ewma.go +++ b/pkg/indicator/ewma.go @@ -16,6 +16,7 @@ const MaxNumOfEWMATruncateSize = 100 //go:generate callbackgen -type EWMA type EWMA struct { types.IntervalWindow + types.SeriesBase Values types.Float64Slice LastOpenTime time.Time @@ -26,6 +27,7 @@ func (inc *EWMA) Update(value float64) { var multiplier = 2.0 / float64(1+inc.Window) if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc inc.Values.Push(value) return } else if len(inc.Values) > MaxNumOfEWMA { @@ -136,4 +138,4 @@ func (inc *EWMA) Bind(updater KLineWindowUpdater) { updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) } -var _ types.Series = &EWMA{} +var _ types.SeriesExtend = &EWMA{} diff --git a/pkg/indicator/hull.go b/pkg/indicator/hull.go index 0c8347f9b..7eaf8ad70 100644 --- a/pkg/indicator/hull.go +++ b/pkg/indicator/hull.go @@ -10,6 +10,7 @@ import ( // Refer URL: https://fidelity.com/learning-center/trading-investing/technical-analysis/technical-indicator-guide/hull-moving-average //go:generate callbackgen -type HULL type HULL struct { + types.SeriesBase types.IntervalWindow ma1 *EWMA ma2 *EWMA @@ -20,6 +21,7 @@ type HULL struct { func (inc *HULL) Update(value float64) { if inc.result == nil { + inc.SeriesBase.Series = inc inc.ma1 = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, inc.Window / 2}} inc.ma2 = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, inc.Window}} inc.result = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, int(math.Sqrt(float64(inc.Window)))}} @@ -50,7 +52,7 @@ func (inc *HULL) Length() int { return inc.result.Length() } -var _ types.Series = &HULL{} +var _ types.SeriesExtend = &HULL{} // TODO: should we just ignore the possible overlapping? func (inc *HULL) calculateAndUpdate(allKLines []types.KLine) { diff --git a/pkg/indicator/line.go b/pkg/indicator/line.go index 763d58f89..a3932ac1c 100644 --- a/pkg/indicator/line.go +++ b/pkg/indicator/line.go @@ -12,6 +12,7 @@ import ( // 3. resistance // of the market data, defined with series interface type Line struct { + types.SeriesBase types.IntervalWindow start float64 end float64 @@ -63,7 +64,7 @@ func (l *Line) SetXY2(index int, value float64) { } func NewLine(startIndex int, startValue float64, endIndex int, endValue float64, interval types.Interval) *Line { - return &Line{ + line := &Line{ start: startValue, end: endValue, startIndex: startIndex, @@ -71,6 +72,8 @@ func NewLine(startIndex int, startValue float64, endIndex int, endValue float64, currentTime: time.Time{}, Interval: interval, } + line.SeriesBase.Series = line + return line } -var _ types.Series = &Line{} +var _ types.SeriesExtend = &Line{} diff --git a/pkg/indicator/macd.go b/pkg/indicator/macd.go index 3dfbd6d45..43e94589b 100644 --- a/pkg/indicator/macd.go +++ b/pkg/indicator/macd.go @@ -87,6 +87,7 @@ func (inc *MACD) Bind(updater KLineWindowUpdater) { } type MACDValues struct { + types.SeriesBase *MACD } @@ -109,10 +110,12 @@ func (inc *MACDValues) Length() int { return len(inc.Values) } -func (inc *MACD) MACD() types.Series { - return &MACDValues{inc} +func (inc *MACD) MACD() types.SeriesExtend { + out := &MACDValues{MACD: inc} + out.SeriesBase.Series = out + return out } -func (inc *MACD) Singals() types.Series { +func (inc *MACD) Singals() types.SeriesExtend { return &inc.SignalLine } diff --git a/pkg/indicator/obv.go b/pkg/indicator/obv.go index 3ea11772d..52321892f 100644 --- a/pkg/indicator/obv.go +++ b/pkg/indicator/obv.go @@ -14,6 +14,7 @@ On-Balance Volume (OBV) Definition */ //go:generate callbackgen -type OBV type OBV struct { + types.SeriesBase types.IntervalWindow Values types.Float64Slice PrePrice float64 @@ -24,6 +25,7 @@ type OBV struct { func (inc *OBV) Update(price, volume float64) { if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc inc.PrePrice = price inc.Values.Push(volume) return @@ -43,6 +45,15 @@ func (inc *OBV) Last() float64 { return inc.Values[len(inc.Values)-1] } +func (inc *OBV) Index(i int) float64 { + if len(inc.Values)-i <= 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-i-1] +} + +var _ types.SeriesExtend = &OBV{} + func (inc *OBV) calculateAndUpdate(kLines []types.KLine) { for _, k := range kLines { if inc.EndTime != zeroTime && !k.EndTime.After(inc.EndTime) { diff --git a/pkg/indicator/rma.go b/pkg/indicator/rma.go index 8fee7a128..857261ecc 100644 --- a/pkg/indicator/rma.go +++ b/pkg/indicator/rma.go @@ -11,6 +11,7 @@ import ( // Refer: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.ewm.html#pandas-dataframe-ewm //go:generate callbackgen -type RMA type RMA struct { + types.SeriesBase types.IntervalWindow Values types.Float64Slice counter int @@ -24,6 +25,7 @@ type RMA struct { func (inc *RMA) Update(x float64) { lambda := 1 / float64(inc.Window) if inc.counter == 0 { + inc.SeriesBase.Series = inc inc.sum = 1 inc.tmp = x } else { @@ -60,7 +62,7 @@ func (inc *RMA) Length() int { return len(inc.Values) } -var _ types.Series = &RMA{} +var _ types.SeriesExtend = &RMA{} func (inc *RMA) calculateAndUpdate(kLines []types.KLine) { for _, k := range kLines { diff --git a/pkg/indicator/rsi.go b/pkg/indicator/rsi.go index b9eabd6f4..8a76322ab 100644 --- a/pkg/indicator/rsi.go +++ b/pkg/indicator/rsi.go @@ -14,6 +14,7 @@ https://www.investopedia.com/terms/r/rsi.asp */ //go:generate callbackgen -type RSI type RSI struct { + types.SeriesBase types.IntervalWindow Values types.Float64Slice Prices types.Float64Slice @@ -25,6 +26,9 @@ type RSI struct { } func (inc *RSI) Update(price float64) { + if len(inc.Prices) == 0 { + inc.SeriesBase.Series = inc + } inc.Prices.Push(price) if len(inc.Prices) < inc.Window+1 { @@ -74,7 +78,7 @@ func (inc *RSI) Length() int { return len(inc.Values) } -var _ types.Series = &RSI{} +var _ types.SeriesExtend = &RSI{} func (inc *RSI) calculateAndUpdate(kLines []types.KLine) { for _, k := range kLines { diff --git a/pkg/indicator/sma.go b/pkg/indicator/sma.go index d500c5d6f..c7e11ac09 100644 --- a/pkg/indicator/sma.go +++ b/pkg/indicator/sma.go @@ -16,6 +16,7 @@ var zeroTime time.Time //go:generate callbackgen -type SMA type SMA struct { + types.SeriesBase types.IntervalWindow Values types.Float64Slice Cache types.Float64Slice @@ -44,10 +45,13 @@ func (inc *SMA) Length() int { return len(inc.Values) } -var _ types.Series = &SMA{} +var _ types.SeriesExtend = &SMA{} func (inc *SMA) Update(value float64) { if len(inc.Cache) < inc.Window { + if len(inc.Cache) == 0 { + inc.SeriesBase.Series = inc + } inc.Cache = append(inc.Cache, value) if len(inc.Cache) == inc.Window { inc.Values = append(inc.Values, types.Mean(&inc.Cache)) diff --git a/pkg/indicator/ssf.go b/pkg/indicator/ssf.go index d8c1340d4..28871a041 100644 --- a/pkg/indicator/ssf.go +++ b/pkg/indicator/ssf.go @@ -20,6 +20,7 @@ import ( // //go:generate callbackgen -type SSF type SSF struct { + types.SeriesBase types.IntervalWindow Poles int c1 float64 @@ -34,6 +35,7 @@ type SSF struct { func (inc *SSF) Update(value float64) { if inc.Poles == 3 { if inc.Values == nil { + inc.SeriesBase.Series = inc x := math.Pi / float64(inc.Window) a0 := math.Exp(-x) b0 := 2. * a0 * math.Cos(math.Sqrt(3.)*x) @@ -53,6 +55,7 @@ func (inc *SSF) Update(value float64) { inc.Values.Push(result) } else { // poles == 2 if inc.Values == nil { + inc.SeriesBase.Series = inc x := math.Pi * math.Sqrt(2.) / float64(inc.Window) a0 := math.Exp(-x) inc.c3 = -a0 * a0 @@ -88,7 +91,7 @@ func (inc *SSF) Last() float64 { return inc.Values.Last() } -var _ types.Series = &SSF{} +var _ types.SeriesExtend = &SSF{} func (inc *SSF) calculateAndUpdate(allKLines []types.KLine) { if inc.Values != nil { diff --git a/pkg/indicator/supertrend.go b/pkg/indicator/supertrend.go index c195dc4e6..d87b41b59 100644 --- a/pkg/indicator/supertrend.go +++ b/pkg/indicator/supertrend.go @@ -12,6 +12,7 @@ var logst = logrus.WithField("indicator", "supertrend") //go:generate callbackgen -type Supertrend type Supertrend struct { + types.SeriesBase types.IntervalWindow ATRMultiplier float64 `json:"atrMultiplier"` @@ -54,6 +55,10 @@ func (inc *Supertrend) Update(highPrice, lowPrice, closePrice float64) { panic("window must be greater than 0") } + if inc.AverageTrueRange == nil { + inc.SeriesBase.Series = inc + } + // Start with DirectionUp if inc.trend != types.DirectionUp && inc.trend != types.DirectionDown { inc.trend = types.DirectionUp @@ -120,7 +125,7 @@ func (inc *Supertrend) GetSignal() types.Direction { return inc.tradeSignal } -var _ types.Series = &Supertrend{} +var _ types.SeriesExtend = &Supertrend{} func (inc *Supertrend) calculateAndUpdate(kLines []types.KLine) { for _, k := range kLines { diff --git a/pkg/indicator/tema.go b/pkg/indicator/tema.go index 91d53a63d..8d1fc3fd3 100644 --- a/pkg/indicator/tema.go +++ b/pkg/indicator/tema.go @@ -9,6 +9,7 @@ import ( //go:generate callbackgen -type TEMA type TEMA struct { + types.SeriesBase types.IntervalWindow Values types.Float64Slice A1 *EWMA @@ -20,6 +21,7 @@ type TEMA struct { func (inc *TEMA) Update(value float64) { if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc inc.A1 = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, inc.Window}} inc.A2 = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, inc.Window}} inc.A3 = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, inc.Window}} @@ -51,7 +53,7 @@ func (inc *TEMA) Length() int { return len(inc.Values) } -var _ types.Series = &TEMA{} +var _ types.SeriesExtend = &TEMA{} func (inc *TEMA) calculateAndUpdate(allKLines []types.KLine) { if inc.A1 == nil { diff --git a/pkg/indicator/till.go b/pkg/indicator/till.go index 73f97ead5..795194e5c 100644 --- a/pkg/indicator/till.go +++ b/pkg/indicator/till.go @@ -10,6 +10,7 @@ const defaultVolumeFactor = 0.7 // Refer URL: https://tradingpedia.com/forex-trading-indicator/t3-moving-average-indicator/ //go:generate callbackgen -type TILL type TILL struct { + types.SeriesBase types.IntervalWindow VolumeFactor float64 e1 *EWMA @@ -30,6 +31,7 @@ func (inc *TILL) Update(value float64) { if inc.VolumeFactor == 0 { inc.VolumeFactor = defaultVolumeFactor } + inc.SeriesBase.Series = inc inc.e1 = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, inc.Window}} inc.e2 = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, inc.Window}} inc.e3 = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, inc.Window}} diff --git a/pkg/indicator/tma.go b/pkg/indicator/tma.go index 482f3936c..c100a987d 100644 --- a/pkg/indicator/tma.go +++ b/pkg/indicator/tma.go @@ -8,6 +8,7 @@ import ( // Refer URL: https://ja.wikipedia.org/wiki/移動平均 //go:generate callbackgen -type TMA type TMA struct { + types.SeriesBase types.IntervalWindow s1 *SMA s2 *SMA @@ -16,6 +17,7 @@ type TMA struct { func (inc *TMA) Update(value float64) { if inc.s1 == nil { + inc.SeriesBase.Series = inc w := (inc.Window + 1) / 2 inc.s1 = &SMA{IntervalWindow: types.IntervalWindow{inc.Interval, w}} inc.s2 = &SMA{IntervalWindow: types.IntervalWindow{inc.Interval, w}} @@ -46,7 +48,7 @@ func (inc *TMA) Length() int { return inc.s2.Length() } -var _ types.Series = &TMA{} +var _ types.SeriesExtend = &TMA{} func (inc *TMA) calculateAndUpdate(allKLines []types.KLine) { if inc.s1 == nil { diff --git a/pkg/indicator/vidya.go b/pkg/indicator/vidya.go index 658e89ac1..cda286e00 100644 --- a/pkg/indicator/vidya.go +++ b/pkg/indicator/vidya.go @@ -10,6 +10,7 @@ import ( // Refer URL: https://metatrader5.com/en/terminal/help/indicators/trend_indicators/vida //go:generate callbackgen -type VIDYA type VIDYA struct { + types.SeriesBase types.IntervalWindow Values types.Float64Slice input types.Float64Slice @@ -19,6 +20,7 @@ type VIDYA struct { func (inc *VIDYA) Update(value float64) { if inc.Values.Length() == 0 { + inc.SeriesBase.Series = inc inc.Values.Push(value) inc.input.Push(value) return @@ -66,7 +68,7 @@ func (inc *VIDYA) Length() int { return inc.Values.Length() } -var _ types.Series = &VIDYA{} +var _ types.SeriesExtend = &VIDYA{} func (inc *VIDYA) calculateAndUpdate(allKLines []types.KLine) { if inc.input.Length() == 0 { diff --git a/pkg/indicator/volatility.go b/pkg/indicator/volatility.go index aae62e283..9f4571408 100644 --- a/pkg/indicator/volatility.go +++ b/pkg/indicator/volatility.go @@ -17,6 +17,7 @@ const MaxNumOfVOLTruncateSize = 100 //go:generate callbackgen -type VOLATILITY type VOLATILITY struct { + types.SeriesBase types.IntervalWindow Values types.Float64Slice EndTime time.Time @@ -31,6 +32,19 @@ func (inc *VOLATILITY) Last() float64 { return inc.Values[len(inc.Values)-1] } +func (inc *VOLATILITY) Index(i int) float64 { + if len(inc.Values)-i <= 0 { + return 0.0 + } + return inc.Values[len(inc.Values)-i-1] +} + +func (inc *VOLATILITY) Length() int { + return len(inc.Values) +} + +var _ types.SeriesExtend = &VOLATILITY{} + func (inc *VOLATILITY) calculateAndUpdate(klines []types.KLine) { if len(klines) < inc.Window { return @@ -42,6 +56,9 @@ func (inc *VOLATILITY) calculateAndUpdate(klines []types.KLine) { if inc.EndTime != zeroTime && lastKLine.GetEndTime().Before(inc.EndTime) { return } + if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc + } var recentT = klines[end-(inc.Window-1) : end+1] diff --git a/pkg/indicator/vwap.go b/pkg/indicator/vwap.go index 7fcac717a..89e3b28e9 100644 --- a/pkg/indicator/vwap.go +++ b/pkg/indicator/vwap.go @@ -17,6 +17,7 @@ Volume-Weighted Average Price (VWAP) Explained */ //go:generate callbackgen -type VWAP type VWAP struct { + types.SeriesBase types.IntervalWindow Values types.Float64Slice Prices types.Float64Slice @@ -29,6 +30,9 @@ type VWAP struct { } func (inc *VWAP) Update(price, volume float64) { + if len(inc.Prices) == 0 { + inc.SeriesBase.Series = inc + } inc.Prices.Push(price) inc.Volumes.Push(volume) @@ -65,7 +69,7 @@ func (inc *VWAP) Length() int { return len(inc.Values) } -var _ types.Series = &VWAP{} +var _ types.SeriesExtend = &VWAP{} func (inc *VWAP) calculateAndUpdate(kLines []types.KLine) { var priceF = KLineTypicalPriceMapper diff --git a/pkg/indicator/vwma.go b/pkg/indicator/vwma.go index 131e2f5df..4ee1068c9 100644 --- a/pkg/indicator/vwma.go +++ b/pkg/indicator/vwma.go @@ -20,6 +20,7 @@ Volume Weighted Moving Average */ //go:generate callbackgen -type VWMA type VWMA struct { + types.SeriesBase types.IntervalWindow Values types.Float64Slice EndTime time.Time @@ -46,7 +47,7 @@ func (inc *VWMA) Length() int { return len(inc.Values) } -var _ types.Series = &VWMA{} +var _ types.SeriesExtend = &VWMA{} func KLinePriceVolumeMapper(k types.KLine) float64 { return k.Close.Mul(k.Volume).Float64() @@ -81,6 +82,10 @@ func (inc *VWMA) calculateAndUpdate(kLines []types.KLine) { return } + if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc + } + vwma := pv / v inc.Values.Push(vwma) diff --git a/pkg/indicator/wwma.go b/pkg/indicator/wwma.go index 13fd1b8d1..be0ec0a7e 100644 --- a/pkg/indicator/wwma.go +++ b/pkg/indicator/wwma.go @@ -14,6 +14,7 @@ const MaxNumOfWWMATruncateSize = 100 //go:generate callbackgen -type WWMA type WWMA struct { + types.SeriesBase types.IntervalWindow Values types.Float64Slice LastOpenTime time.Time @@ -23,6 +24,7 @@ type WWMA struct { func (inc *WWMA) Update(value float64) { if len(inc.Values) == 0 { + inc.SeriesBase.Series = inc inc.Values.Push(value) return } else if len(inc.Values) > MaxNumOfWWMA { @@ -85,4 +87,4 @@ func (inc *WWMA) Bind(updater KLineWindowUpdater) { updater.OnKLineWindowUpdate(inc.handleKLineWindowUpdate) } -var _ types.Series = &WWMA{} +var _ types.SeriesExtend = &WWMA{} diff --git a/pkg/indicator/zlema.go b/pkg/indicator/zlema.go index 4ed97d84a..f127c0008 100644 --- a/pkg/indicator/zlema.go +++ b/pkg/indicator/zlema.go @@ -9,6 +9,7 @@ import ( //go:generate callbackgen -type ZLEMA type ZLEMA struct { + types.SeriesBase types.IntervalWindow data types.Float64Slice @@ -41,6 +42,7 @@ func (inc *ZLEMA) Length() int { func (inc *ZLEMA) Update(value float64) { if inc.lag == 0 || inc.zlema == nil { + inc.SeriesBase.Series = inc inc.zlema = &EWMA{IntervalWindow: types.IntervalWindow{inc.Interval, inc.Window}} inc.lag = int((float64(inc.Window)-1.)/2. + 0.5) } @@ -55,7 +57,7 @@ func (inc *ZLEMA) Update(value float64) { inc.zlema.Update(emaData) } -var _ types.Series = &ZLEMA{} +var _ types.SeriesExtend = &ZLEMA{} func (inc *ZLEMA) calculateAndUpdate(allKLines []types.KLine) { if inc.zlema == nil { diff --git a/pkg/interact/interact.go b/pkg/interact/interact.go index a19b56210..820979cfe 100644 --- a/pkg/interact/interact.go +++ b/pkg/interact/interact.go @@ -112,7 +112,7 @@ func (it *Interact) handleResponse(session Session, text string, ctxObjects ...i } ctxObjects = append(ctxObjects, session) - _, err := parseFuncArgsAndCall(f, args, ctxObjects...) + _, err := ParseFuncArgsAndCall(f, args, ctxObjects...) if err != nil { return err } @@ -154,7 +154,7 @@ func (it *Interact) runCommand(session Session, command string, args []string, c ctxObjects = append(ctxObjects, session) session.SetState(cmd.initState) - if _, err := parseFuncArgsAndCall(cmd.F, args, ctxObjects...); err != nil { + if _, err := ParseFuncArgsAndCall(cmd.F, args, ctxObjects...); err != nil { return err } diff --git a/pkg/interact/interact_test.go b/pkg/interact/interact_test.go index bd0828240..8402ba1c8 100644 --- a/pkg/interact/interact_test.go +++ b/pkg/interact/interact_test.go @@ -18,7 +18,7 @@ func Test_parseFuncArgsAndCall_NoErrorFunction(t *testing.T) { return nil } - _, err := parseFuncArgsAndCall(noErrorFunc, []string{"BTCUSDT", "0.123", "true"}) + _, err := ParseFuncArgsAndCall(noErrorFunc, []string{"BTCUSDT", "0.123", "true"}) assert.NoError(t, err) } @@ -27,7 +27,7 @@ func Test_parseFuncArgsAndCall_ErrorFunction(t *testing.T) { return errors.New("error") } - _, err := parseFuncArgsAndCall(errorFunc, []string{"BTCUSDT", "0.123"}) + _, err := ParseFuncArgsAndCall(errorFunc, []string{"BTCUSDT", "0.123"}) assert.Error(t, err) } @@ -38,7 +38,7 @@ func Test_parseFuncArgsAndCall_InterfaceInjection(t *testing.T) { } buf := bytes.NewBuffer(nil) - _, err := parseFuncArgsAndCall(f, []string{"BTCUSDT", "0.123"}, buf) + _, err := ParseFuncArgsAndCall(f, []string{"BTCUSDT", "0.123"}, buf) assert.NoError(t, err) assert.Equal(t, "123", buf.String()) } diff --git a/pkg/interact/parse.go b/pkg/interact/parse.go index db4f3d1fd..64f55871b 100644 --- a/pkg/interact/parse.go +++ b/pkg/interact/parse.go @@ -10,21 +10,20 @@ import ( log "github.com/sirupsen/logrus" ) -func parseFuncArgsAndCall(f interface{}, args []string, objects ...interface{}) (State, error) { +func ParseFuncArgsAndCall(f interface{}, args []string, objects ...interface{}) (State, error) { fv := reflect.ValueOf(f) ft := reflect.TypeOf(f) - argIndex := 0 var rArgs []reflect.Value for i := 0; i < ft.NumIn(); i++ { at := ft.In(i) + // get the kind of argument switch k := at.Kind(); k { case reflect.Interface: found := false - for oi := 0; oi < len(objects); oi++ { obj := objects[oi] objT := reflect.TypeOf(obj) @@ -90,8 +89,8 @@ func parseFuncArgsAndCall(f interface{}, args []string, objects ...interface{}) } // try to get the error object from the return value - var state State var err error + var state State for i := 0; i < ft.NumOut(); i++ { outType := ft.Out(i) switch outType.Kind() { @@ -107,7 +106,6 @@ func parseFuncArgsAndCall(f interface{}, args []string, objects ...interface{}) err = ov } - } } return state, err diff --git a/pkg/optimizer/grid.go b/pkg/optimizer/grid.go index b9aec0f59..0b76ca886 100644 --- a/pkg/optimizer/grid.go +++ b/pkg/optimizer/grid.go @@ -4,10 +4,11 @@ import ( "context" "encoding/json" "fmt" - "github.com/cheggaaa/pb/v3" "sort" - "github.com/evanphx/json-patch/v5" + "github.com/cheggaaa/pb/v3" + + jsonpatch "github.com/evanphx/json-patch/v5" "github.com/c9s/bbgo/pkg/backtest" "github.com/c9s/bbgo/pkg/fixedpoint" @@ -19,10 +20,28 @@ var TotalProfitMetricValueFunc = func(summaryReport *backtest.SummaryReport) fix return summaryReport.TotalProfit } +var TotalVolume = func(summaryReport *backtest.SummaryReport) fixedpoint.Value { + if len(summaryReport.SymbolReports) == 0 { + return fixedpoint.Zero + } + + buyVolume := summaryReport.SymbolReports[0].PnL.BuyVolume + sellVolume := summaryReport.SymbolReports[0].PnL.SellVolume + return buyVolume.Add(sellVolume) +} + type Metric struct { - Labels []string `json:"labels,omitempty"` - Params []interface{} `json:"params,omitempty"` - Value fixedpoint.Value `json:"value,omitempty"` + // Labels is the labels of the given parameters + Labels []string `json:"labels,omitempty"` + + // Params is the parameters used to output the metrics result + Params []interface{} `json:"params,omitempty"` + + // Key is the metric name + Key string `json:"key"` + + // Value is the metric value of the metric + Value fixedpoint.Value `json:"value,omitempty"` } func copyParams(params []interface{}) []interface{} { @@ -168,6 +187,7 @@ func (o *GridOptimizer) Run(executor Executor, configJson []byte) (map[string][] var valueFunctions = map[string]MetricValueFunc{ "totalProfit": TotalProfitMetricValueFunc, + "totalVolume": TotalVolume, } var metrics = map[string][]Metric{} @@ -216,13 +236,20 @@ func (o *GridOptimizer) Run(executor Executor, configJson []byte) (map[string][] close(taskC) // this will shut down the executor for result := range resultsC { - for metricName, metricFunc := range valueFunctions { + if result.Report == nil { + log.Errorf("no summaryReport found for params: %+v", result.Params) + continue + } + + for metricKey, metricFunc := range valueFunctions { var metricValue = metricFunc(result.Report) - bar.Set("log", fmt.Sprintf("params: %+v => %s %+v", result.Params, metricName, metricValue)) + bar.Set("log", fmt.Sprintf("params: %+v => %s %+v", result.Params, metricKey, metricValue)) bar.Increment() - metrics[metricName] = append(metrics[metricName], Metric{ + + metrics[metricKey] = append(metrics[metricKey], Metric{ Params: result.Params, Labels: result.Labels, + Key: metricKey, Value: metricValue, }) } diff --git a/pkg/statistics/omega.go b/pkg/statistics/omega.go new file mode 100644 index 000000000..12c0dbc25 --- /dev/null +++ b/pkg/statistics/omega.go @@ -0,0 +1 @@ +package statistics diff --git a/pkg/statistics/sharp.go b/pkg/statistics/sharp.go new file mode 100644 index 000000000..bf2114249 --- /dev/null +++ b/pkg/statistics/sharp.go @@ -0,0 +1,34 @@ +package statistics + +import ( + "math" + + "github.com/c9s/bbgo/pkg/types" +) + +// Sharpe: Calcluates the sharpe ratio of access returns +// +// @param periods (int): Freq. of returns (252/365 for daily, 12 for monthy) +// @param annualize (bool): return annualize sharpe? +// @param smart (bool): return smart sharpe ratio +func Sharpe(returns types.Series, periods int, annualize bool, smart bool) float64 { + data := returns + num := data.Length() + if types.Lowest(data, num) >= 0 && types.Highest(data, num) > 1 { + data = types.PercentageChange(returns) + } + divisor := types.Stdev(data, data.Length(), 1) + if smart { + sum := 0. + coef := math.Abs(types.Correlation(data, types.Shift(data, 1), num-1)) + for i := 1; i < num; i++ { + sum += float64(num-i) / float64(num) * math.Pow(coef, float64(i)) + } + divisor = divisor * math.Sqrt(1.+2.*sum) + } + result := types.Mean(data) / divisor + if annualize { + return result * math.Sqrt(float64(periods)) + } + return result +} diff --git a/pkg/statistics/sharp_test.go b/pkg/statistics/sharp_test.go new file mode 100644 index 000000000..7d373301c --- /dev/null +++ b/pkg/statistics/sharp_test.go @@ -0,0 +1,27 @@ +package statistics + +import ( + "github.com/c9s/bbgo/pkg/types" + "github.com/stretchr/testify/assert" + "testing" +) + +/* +python + +import quantstats as qx +import pandas as pd + +print(qx.stats.sharpe(pd.Series([0.01, 0.1, 0.001]), 0, 0, False, False)) +print(qx.stats.sharpe(pd.Series([0.01, 0.1, 0.001]), 0, 252, False, False)) +print(qx.stats.sharpe(pd.Series([0.01, 0.1, 0.001]), 0, 252, True, False)) +*/ +func TestSharpe(t *testing.T) { + var a types.Series = &types.Float64Slice{0.01, 0.1, 0.001} + output := Sharpe(a, 0, false, false) + assert.InDelta(t, output, 0.67586, 0.0001) + output = Sharpe(a, 252, false, false) + assert.InDelta(t, output, 0.67586, 0.0001) + output = Sharpe(a, 252, true, false) + assert.InDelta(t, output, 10.7289, 0.0001) +} diff --git a/pkg/statistics/sortino.go b/pkg/statistics/sortino.go new file mode 100644 index 000000000..12c0dbc25 --- /dev/null +++ b/pkg/statistics/sortino.go @@ -0,0 +1 @@ +package statistics diff --git a/pkg/strategy/bollgrid/strategy.go b/pkg/strategy/bollgrid/strategy.go index 8d689f7d4..676c8fea0 100644 --- a/pkg/strategy/bollgrid/strategy.go +++ b/pkg/strategy/bollgrid/strategy.go @@ -41,9 +41,6 @@ type Strategy struct { // This field will be injected automatically since we defined the Symbol field. *bbgo.StandardIndicatorSet - // Graceful let you define the graceful shutdown handler - *bbgo.Graceful - // Market stores the configuration of the market, for example, VolumePrecision, PricePrecision, MinLotSize... etc // This field will be injected automatically since we defined the Symbol field. types.Market @@ -350,7 +347,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.profitOrders.BindStream(session.UserDataStream) // setup graceful shutting down handler - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { // call Done to notify the main process. defer wg.Done() log.Infof("canceling active orders...") diff --git a/pkg/strategy/bollmaker/strategy.go b/pkg/strategy/bollmaker/strategy.go index 93b135b9d..ff3c31eee 100644 --- a/pkg/strategy/bollmaker/strategy.go +++ b/pkg/strategy/bollmaker/strategy.go @@ -49,7 +49,6 @@ type BollingerSetting struct { } type Strategy struct { - *bbgo.Graceful *bbgo.Persistence Environment *bbgo.Environment @@ -216,18 +215,6 @@ func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Valu return s.orderExecutor.ClosePosition(ctx, percentage) } -// Deprecated: LoadState method is migrated to the persistence struct tag. -func (s *Strategy) LoadState() error { - var state State - - // load position - if err := s.Persistence.Load(&state, ID, s.Symbol, stateKey); err == nil { - s.state = &state - } - - return nil -} - func (s *Strategy) getCurrentAllowedExposurePosition(bandPercentage float64) (fixedpoint.Value, error) { if s.DynamicExposurePositionScale != nil { v, err := s.DynamicExposurePositionScale.Scale(bandPercentage) @@ -506,17 +493,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se // If position is nil, we need to allocate a new position for calculation if s.Position == nil { - // restore state (legacy) - if err := s.LoadState(); err != nil { - return err - } - - // fallback to the legacy position struct in the state - if s.state != nil && s.state.Position != nil && !s.state.Position.Base.IsZero() { - s.Position = s.state.Position - } else { - s.Position = types.NewPositionFromMarket(s.Market) - } + s.Position = types.NewPositionFromMarket(s.Market) } if s.session.MakerFeeRate.Sign() > 0 || s.session.TakerFeeRate.Sign() > 0 { @@ -527,13 +504,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } if s.ProfitStats == nil { - if s.state != nil { - // copy profit stats - p2 := s.state.ProfitStats - s.ProfitStats = &p2 - } else { - s.ProfitStats = types.NewProfitStats(s.Market) - } + s.ProfitStats = types.NewProfitStats(s.Market) } // Always update the position fields @@ -616,7 +587,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se // s.book = types.NewStreamBook(s.Symbol) // s.book.BindStreamForBackground(session.MarketDataStream) - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() _ = s.orderExecutor.GracefulCancel(ctx) diff --git a/pkg/strategy/dca/strategy.go b/pkg/strategy/dca/strategy.go index f0e86aa53..38e6c9d14 100644 --- a/pkg/strategy/dca/strategy.go +++ b/pkg/strategy/dca/strategy.go @@ -47,7 +47,6 @@ func (b BudgetPeriod) Duration() time.Duration { // Strategy is the Dollar-Cost-Average strategy type Strategy struct { - *bbgo.Graceful Environment *bbgo.Environment Symbol string `json:"symbol"` diff --git a/pkg/strategy/emastop/strategy.go b/pkg/strategy/emastop/strategy.go index 89c837b37..7c9c4a190 100644 --- a/pkg/strategy/emastop/strategy.go +++ b/pkg/strategy/emastop/strategy.go @@ -25,7 +25,6 @@ func init() { } type Strategy struct { - *bbgo.Graceful SourceExchangeName string `json:"sourceExchange"` @@ -217,7 +216,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.place(ctx, orderExecutor, session, indicator, closePrice) }) - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() log.Infof("canceling trailingstop order...") s.clear(ctx, orderExecutor) @@ -261,7 +260,7 @@ func (s *Strategy) CrossRun(ctx context.Context, _ bbgo.OrderExecutionRouter, se s.place(ctx, &orderExecutor, session, indicator, closePrice) }) - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() log.Infof("canceling trailingstop order...") s.clear(ctx, &orderExecutor) diff --git a/pkg/strategy/ewoDgtrd/strategy.go b/pkg/strategy/ewoDgtrd/strategy.go index e777bcb9d..4a926c901 100644 --- a/pkg/strategy/ewoDgtrd/strategy.go +++ b/pkg/strategy/ewoDgtrd/strategy.go @@ -51,7 +51,6 @@ type Strategy struct { KLineEndTime types.Time *bbgo.Environment - *bbgo.Graceful bbgo.StrategyController activeMakerOrders *bbgo.ActiveOrderBook @@ -63,11 +62,11 @@ type Strategy struct { atr *indicator.ATR emv *indicator.EMV ccis *CCISTOCH - ma5 types.Series - ma34 types.Series - ewo types.Series - ewoSignal types.Series - ewoHistogram types.Series + ma5 types.SeriesExtend + ma34 types.SeriesExtend + ewo types.SeriesExtend + ewoSignal types.SeriesExtend + ewoHistogram types.SeriesExtend ewoChangeRate float64 heikinAshi *HeikinAshi peakPrice fixedpoint.Value @@ -331,12 +330,12 @@ func (s *Strategy) SetupIndicators(store *bbgo.MarketDataStore) { evwma34.UpdateVal(price, vol) } }) - s.ma5 = evwma5 - s.ma34 = evwma34 + s.ma5 = types.NewSeries(evwma5) + s.ma34 = types.NewSeries(evwma34) } - s.ewo = types.Mul(types.Minus(types.Div(s.ma5, s.ma34), 1.0), 100.) - s.ewoHistogram = types.Minus(s.ma5, s.ma34) + s.ewo = s.ma5.Div(s.ma34).Minus(1.0).Mul(100.) + s.ewoHistogram = s.ma5.Minus(s.ma34) windowSignal := types.IntervalWindow{Interval: s.Interval, Window: s.SignalWindow} if s.UseEma { sig := &indicator.EWMA{IntervalWindow: windowSignal} @@ -365,7 +364,7 @@ func (s *Strategy) SetupIndicators(store *bbgo.MarketDataStore) { if sig.Length() == 0 { // lazy init - ewoVals := types.Reverse(s.ewo) + ewoVals := s.ewo.Reverse() for _, ewoValue := range ewoVals { sig.Update(ewoValue) } @@ -385,7 +384,7 @@ func (s *Strategy) SetupIndicators(store *bbgo.MarketDataStore) { } if sig.Length() == 0 { // lazy init - ewoVals := types.Reverse(s.ewo) + ewoVals := s.ewo.Reverse() for i, ewoValue := range ewoVals { vol := window.Volume().Index(i) sig.PV.Update(ewoValue * vol) @@ -397,7 +396,7 @@ func (s *Strategy) SetupIndicators(store *bbgo.MarketDataStore) { sig.V.Update(vol) } }) - s.ewoSignal = sig + s.ewoSignal = types.NewSeries(sig) } } @@ -1221,7 +1220,8 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } } }) - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() log.Infof("canceling active orders...") s.CancelAll(ctx) diff --git a/pkg/strategy/flashcrash/strategy.go b/pkg/strategy/flashcrash/strategy.go index b15fcbfb7..4b5c80577 100644 --- a/pkg/strategy/flashcrash/strategy.go +++ b/pkg/strategy/flashcrash/strategy.go @@ -49,10 +49,6 @@ type Strategy struct { // This field will be injected automatically since we defined the Symbol field. *bbgo.StandardIndicatorSet - // Graceful shutdown function - *bbgo.Graceful - // -------------------------- - // ewma is the exponential weighted moving average indicator ewma *indicator.EWMA } @@ -114,7 +110,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.activeOrders = bbgo.NewActiveOrderBook(s.Symbol) s.activeOrders.BindStream(session.UserDataStream) - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() log.Infof("canceling active orders...") diff --git a/pkg/strategy/fmaker/strategy.go b/pkg/strategy/fmaker/strategy.go index d67367569..c6f7068be 100644 --- a/pkg/strategy/fmaker/strategy.go +++ b/pkg/strategy/fmaker/strategy.go @@ -31,7 +31,6 @@ type IntervalWindowSetting struct { } type Strategy struct { - *bbgo.Graceful *bbgo.Persistence Environment *bbgo.Environment diff --git a/pkg/strategy/grid/strategy.go b/pkg/strategy/grid/strategy.go index 75e781783..7fc899576 100644 --- a/pkg/strategy/grid/strategy.go +++ b/pkg/strategy/grid/strategy.go @@ -45,8 +45,6 @@ type State struct { } type Strategy struct { - *bbgo.Graceful `json:"-" yaml:"-"` - *bbgo.Persistence // OrderExecutor is an interface for submitting order. @@ -621,7 +619,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se }) s.tradeCollector.BindStream(session.UserDataStream) - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() if err := s.SaveState(); err != nil { diff --git a/pkg/strategy/pivotshort/breaklow.go b/pkg/strategy/pivotshort/breaklow.go new file mode 100644 index 000000000..ae61967c3 --- /dev/null +++ b/pkg/strategy/pivotshort/breaklow.go @@ -0,0 +1,191 @@ +package pivotshort + +import ( + "context" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/indicator" + "github.com/c9s/bbgo/pkg/types" +) + +// BreakLow -- when price breaks the previous pivot low, we set a trade entry +type BreakLow struct { + Symbol string + Market types.Market + types.IntervalWindow + + // Ratio is a number less than 1.0, price * ratio will be the price triggers the short order. + Ratio fixedpoint.Value `json:"ratio"` + + // MarketOrder is the option to enable market order short. + MarketOrder bool `json:"marketOrder"` + + // BounceRatio is a ratio used for placing the limit order sell price + // limit sell price = breakLowPrice * (1 + BounceRatio) + BounceRatio fixedpoint.Value `json:"bounceRatio"` + + Quantity fixedpoint.Value `json:"quantity"` + StopEMARange fixedpoint.Value `json:"stopEMARange"` + StopEMA *types.IntervalWindow `json:"stopEMA"` + + lastLow fixedpoint.Value + pivot *indicator.Pivot + stopEWMA *indicator.EWMA + pivotLowPrices []fixedpoint.Value + + orderExecutor *bbgo.GeneralOrderExecutor + session *bbgo.ExchangeSession +} + +func (s *BreakLow) Subscribe(session *bbgo.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *BreakLow) Bind(session *bbgo.ExchangeSession, orderExecutor *bbgo.GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + position := orderExecutor.Position() + symbol := position.Symbol + store, _ := session.MarketDataStore(symbol) + standardIndicator, _ := session.StandardIndicatorSet(symbol) + + s.lastLow = fixedpoint.Zero + + s.pivot = &indicator.Pivot{IntervalWindow: s.IntervalWindow} + s.pivot.Bind(store) + preloadPivot(s.pivot, store) + + if s.StopEMA != nil { + s.stopEWMA = standardIndicator.EWMA(*s.StopEMA) + } + + // update pivot low data + session.MarketDataStream.OnKLineClosed(types.KLineWith(symbol, s.Interval, func(kline types.KLine) { + lastLow := fixedpoint.NewFromFloat(s.pivot.LastLow()) + if lastLow.IsZero() { + return + } + + if lastLow.Compare(s.lastLow) != 0 { + log.Infof("new pivot low detected: %f %s", s.pivot.LastLow(), kline.EndTime.Time()) + } + + s.lastLow = lastLow + s.pivotLowPrices = append(s.pivotLowPrices, s.lastLow) + })) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(symbol, types.Interval1m, func(kline types.KLine) { + if position.IsOpened(kline.Close) { + return + } + + if len(s.pivotLowPrices) == 0 { + log.Infof("currently there is no pivot low prices, can not check break low...") + return + } + + previousLow := s.pivotLowPrices[len(s.pivotLowPrices)-1] + ratio := fixedpoint.One.Add(s.Ratio) + breakPrice := previousLow.Mul(ratio) + + openPrice := kline.Open + closePrice := kline.Close + + // if the previous low is not break, or the kline is not strong enough to break it, skip + if closePrice.Compare(breakPrice) >= 0 { + return + } + + // we need the price cross the break line, or we do nothing: + // open > break price > close price + if !(openPrice.Compare(breakPrice) > 0 && closePrice.Compare(breakPrice) < 0) { + return + } + + // force direction to be down + if closePrice.Compare(openPrice) >= 0 { + log.Infof("%s price %f is closed higher than the open price %f, skip this break", kline.Symbol, closePrice.Float64(), openPrice.Float64()) + // skip UP klines + return + } + + log.Infof("%s breakLow signal detected, closed price %f < breakPrice %f", kline.Symbol, closePrice.Float64(), breakPrice.Float64()) + + // stop EMA protection + if s.stopEWMA != nil { + ema := fixedpoint.NewFromFloat(s.stopEWMA.Last()) + if ema.IsZero() { + return + } + + emaStopShortPrice := ema.Mul(fixedpoint.One.Sub(s.StopEMARange)) + if closePrice.Compare(emaStopShortPrice) < 0 { + log.Infof("stopEMA protection: close price %f < EMA(%v) = %f", closePrice.Float64(), s.StopEMA, ema.Float64()) + return + } + } + + ctx := context.Background() + + // graceful cancel all active orders + _ = orderExecutor.GracefulCancel(ctx) + + quantity := s.useQuantityOrBaseBalance(s.Quantity) + if s.MarketOrder { + bbgo.Notify("%s price %f breaks the previous low %f with ratio %f, submitting market sell to open a short position", symbol, kline.Close.Float64(), previousLow.Float64(), s.Ratio.Float64()) + _, _ = s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeMarket, + Quantity: quantity, + MarginSideEffect: types.SideEffectTypeMarginBuy, + Tag: "breakLowMarket", + }) + + } else { + sellPrice := previousLow.Mul(fixedpoint.One.Add(s.BounceRatio)) + + bbgo.Notify("%s price %f breaks the previous low %f with ratio %f, submitting limit sell @ %f", symbol, kline.Close.Float64(), previousLow.Float64(), s.Ratio.Float64(), sellPrice.Float64()) + _, _ = s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: kline.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimit, + Price: sellPrice, + Quantity: quantity, + MarginSideEffect: types.SideEffectTypeMarginBuy, + Tag: "breakLowLimit", + }) + } + })) + + if !bbgo.IsBackTesting { + // use market trade to submit short order + session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { + + }) + } +} + +func (s *BreakLow) useQuantityOrBaseBalance(quantity fixedpoint.Value) fixedpoint.Value { + if s.session.Margin || s.session.IsolatedMargin || s.session.Futures || s.session.IsolatedFutures { + return quantity + } + + balance, hasBalance := s.session.Account.Balance(s.Market.BaseCurrency) + if hasBalance { + if quantity.IsZero() { + bbgo.Notify("sell quantity is not set, submitting sell with all base balance: %s", balance.Available.String()) + quantity = balance.Available + } else { + quantity = fixedpoint.Min(quantity, balance.Available) + } + } + + if quantity.IsZero() { + log.Errorf("quantity is zero, can not submit sell order, please check settings") + } + + return quantity +} diff --git a/pkg/strategy/pivotshort/math.go b/pkg/strategy/pivotshort/math.go new file mode 100644 index 000000000..9f5df82f2 --- /dev/null +++ b/pkg/strategy/pivotshort/math.go @@ -0,0 +1,66 @@ +package pivotshort + +import "sort" + +func lower(arr []float64, x float64) []float64 { + sort.Float64s(arr) + + var rst []float64 + for _, a := range arr { + // filter prices that are lower than the current closed price + if a > x { + continue + } + + rst = append(rst, a) + } + + return rst +} + +func higher(arr []float64, x float64) []float64 { + sort.Float64s(arr) + + var rst []float64 + for _, a := range arr { + // filter prices that are lower than the current closed price + if a < x { + continue + } + rst = append(rst, a) + } + + return rst +} + +func group(arr []float64, minDistance float64) []float64 { + if len(arr) == 0 { + return nil + } + + var groups []float64 + var grp = []float64{arr[0]} + for _, price := range arr { + avg := average(grp) + if (price / avg) > (1.0 + minDistance) { + groups = append(groups, avg) + grp = []float64{price} + } else { + grp = append(grp, price) + } + } + + if len(grp) > 0 { + groups = append(groups, average(grp)) + } + + return groups +} + +func average(arr []float64) float64 { + s := 0.0 + for _, a := range arr { + s += a + } + return s / float64(len(arr)) +} diff --git a/pkg/strategy/pivotshort/resistance.go b/pkg/strategy/pivotshort/resistance.go new file mode 100644 index 000000000..b2726f98d --- /dev/null +++ b/pkg/strategy/pivotshort/resistance.go @@ -0,0 +1,190 @@ +package pivotshort + +import ( + "context" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/indicator" + "github.com/c9s/bbgo/pkg/types" +) + +type ResistanceShort struct { + Enabled bool `json:"enabled"` + Symbol string `json:"-"` + Market types.Market `json:"-"` + + types.IntervalWindow + + MinDistance fixedpoint.Value `json:"minDistance"` + GroupDistance fixedpoint.Value `json:"groupDistance"` + NumLayers int `json:"numLayers"` + LayerSpread fixedpoint.Value `json:"layerSpread"` + Quantity fixedpoint.Value `json:"quantity"` + Ratio fixedpoint.Value `json:"ratio"` + + session *bbgo.ExchangeSession + orderExecutor *bbgo.GeneralOrderExecutor + + resistancePivot *indicator.Pivot + resistancePrices []float64 + currentResistancePrice fixedpoint.Value + + activeOrders *bbgo.ActiveOrderBook +} + +func (s *ResistanceShort) Bind(session *bbgo.ExchangeSession, orderExecutor *bbgo.GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + s.activeOrders = bbgo.NewActiveOrderBook(s.Symbol) + s.activeOrders.BindStream(session.UserDataStream) + + if s.GroupDistance.IsZero() { + s.GroupDistance = fixedpoint.NewFromFloat(0.01) + } + + store, _ := session.MarketDataStore(s.Symbol) + + s.resistancePivot = &indicator.Pivot{IntervalWindow: s.IntervalWindow} + s.resistancePivot.Bind(store) + + // preload history kline data to the resistance pivot indicator + // we use the last kline to find the higher lows + lastKLine := preloadPivot(s.resistancePivot, store) + + // use the last kline from the history before we get the next closed kline + if lastKLine != nil { + s.findNextResistancePriceAndPlaceOrders(lastKLine.Close) + } + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + position := s.orderExecutor.Position() + if position.IsOpened(kline.Close) { + return + } + + s.findNextResistancePriceAndPlaceOrders(kline.Close) + })) +} + +func tail(arr []float64, length int) []float64 { + if len(arr) < length { + return arr + } + + return arr[len(arr)-1-length:] +} + +func (s *ResistanceShort) updateNextResistancePrice(closePrice fixedpoint.Value) bool { + minDistance := s.MinDistance.Float64() + groupDistance := s.GroupDistance.Float64() + resistancePrices := findPossibleResistancePrices(closePrice.Float64()*(1.0+minDistance), groupDistance, tail(s.resistancePivot.Lows, 6)) + + if len(resistancePrices) == 0 { + return false + } + + log.Infof("%s close price: %f, min distance: %f, possible resistance prices: %+v", s.Symbol, closePrice.Float64(), minDistance, resistancePrices) + + nextResistancePrice := fixedpoint.NewFromFloat(resistancePrices[0]) + + // if currentResistancePrice is not set or the close price is already higher than the current resistance price, + // we should update the resistance price + // if the detected resistance price is lower than the current one, we should also update it too + if s.currentResistancePrice.IsZero() { + s.currentResistancePrice = nextResistancePrice + return true + } + + // if the current sell price is out-dated + // or + // the next resistance is lower than the current one. + currentSellPrice := s.currentResistancePrice.Mul(one.Add(s.Ratio)) + if closePrice.Compare(currentSellPrice) > 0 || + nextResistancePrice.Compare(currentSellPrice) < 0 { + s.currentResistancePrice = nextResistancePrice + return true + } + + return false +} + +func (s *ResistanceShort) findNextResistancePriceAndPlaceOrders(closePrice fixedpoint.Value) { + ctx := context.Background() + resistanceUpdated := s.updateNextResistancePrice(closePrice) + if resistanceUpdated { + bbgo.Notify("Found next resistance price: %f, updating resistance order...", s.currentResistancePrice.Float64()) + s.placeResistanceOrders(ctx, s.currentResistancePrice) + } +} + +func (s *ResistanceShort) placeResistanceOrders(ctx context.Context, resistancePrice fixedpoint.Value) { + futuresMode := s.session.Futures || s.session.IsolatedFutures + _ = futuresMode + + totalQuantity := s.Quantity + numLayers := s.NumLayers + if numLayers == 0 { + numLayers = 1 + } + + numLayersF := fixedpoint.NewFromInt(int64(numLayers)) + layerSpread := s.LayerSpread + quantity := totalQuantity.Div(numLayersF) + + if s.activeOrders.NumOfOrders() > 0 { + if err := s.orderExecutor.GracefulCancelActiveOrderBook(ctx, s.activeOrders); err != nil { + log.WithError(err).Errorf("can not cancel resistance orders: %+v", s.activeOrders.Orders()) + } + } + + log.Infof("placing resistance orders: resistance price = %f, layer quantity = %f, num of layers = %d", resistancePrice.Float64(), quantity.Float64(), numLayers) + + var sellPriceStart = resistancePrice.Mul(fixedpoint.One.Add(s.Ratio)) + var orderForms []types.SubmitOrder + for i := 0; i < numLayers; i++ { + balances := s.session.GetAccount().Balances() + quoteBalance := balances[s.Market.QuoteCurrency] + baseBalance := balances[s.Market.BaseCurrency] + _ = quoteBalance + _ = baseBalance + + spread := layerSpread.Mul(fixedpoint.NewFromInt(int64(i))) + price := sellPriceStart.Mul(one.Add(spread)) + log.Infof("price = %f", price.Float64()) + + log.Infof("placing resistance short order #%d: price = %f, quantity = %f", i, price.Float64(), quantity.Float64()) + + orderForms = append(orderForms, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeLimitMaker, + Price: price, + Quantity: quantity, + Tag: "resistanceShort", + MarginSideEffect: types.SideEffectTypeMarginBuy, + }) + + // TODO: fix futures mode later + /* + if futuresMode { + if quantity.Mul(price).Compare(quoteBalance.Available) <= 0 { + } + } + */ + } + + createdOrders, err := s.orderExecutor.SubmitOrders(ctx, orderForms...) + if err != nil { + log.WithError(err).Errorf("can not place resistance order") + } + s.activeOrders.Add(createdOrders...) +} + +func findPossibleSupportPrices(closePrice float64, groupDistance float64, lows []float64) []float64 { + return group(lower(lows, closePrice), groupDistance) +} + +func findPossibleResistancePrices(closePrice float64, groupDistance float64, lows []float64) []float64 { + return group(higher(lows, closePrice), groupDistance) +} diff --git a/pkg/strategy/pivotshort/resistance_test.go b/pkg/strategy/pivotshort/resistance_test.go new file mode 100644 index 000000000..6a4601771 --- /dev/null +++ b/pkg/strategy/pivotshort/resistance_test.go @@ -0,0 +1,28 @@ +package pivotshort + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_findPossibleResistancePrices(t *testing.T) { + prices := findPossibleResistancePrices(19000.0, 0.01, []float64{ + 23020.0, + 23040.0, + 23060.0, + + 24020.0, + 24040.0, + 24060.0, + }) + assert.Equal(t, []float64{23035, 24040}, prices) + + + prices = findPossibleResistancePrices(19000.0, 0.01, []float64{ + 23020.0, + 23040.0, + 23060.0, + }) + assert.Equal(t, []float64{23035}, prices) +} diff --git a/pkg/strategy/pivotshort/strategy.go b/pkg/strategy/pivotshort/strategy.go index 69cfa6cdb..1d9acf8b6 100644 --- a/pkg/strategy/pivotshort/strategy.go +++ b/pkg/strategy/pivotshort/strategy.go @@ -4,12 +4,12 @@ import ( "context" "fmt" "os" - "sort" "sync" "github.com/sirupsen/logrus" "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/dynamic" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/indicator" "github.com/c9s/bbgo/pkg/types" @@ -30,47 +30,124 @@ type IntervalWindowSetting struct { types.IntervalWindow } -// BreakLow -- when price breaks the previous pivot low, we set a trade entry -type BreakLow struct { - // Ratio is a number less than 1.0, price * ratio will be the price triggers the short order. - Ratio fixedpoint.Value `json:"ratio"` - - // MarketOrder is the option to enable market order short. - MarketOrder bool `json:"marketOrder"` - - // BounceRatio is a ratio used for placing the limit order sell price - // limit sell price = breakLowPrice * (1 + BounceRatio) - BounceRatio fixedpoint.Value `json:"bounceRatio"` - - Quantity fixedpoint.Value `json:"quantity"` - StopEMARange fixedpoint.Value `json:"stopEMARange"` - StopEMA *types.IntervalWindow `json:"stopEMA"` -} - -type BounceShort struct { - Enabled bool `json:"enabled"` - +type SupportTakeProfit struct { + Symbol string types.IntervalWindow - MinDistance fixedpoint.Value `json:"minDistance"` - NumLayers int `json:"numLayers"` - LayerSpread fixedpoint.Value `json:"layerSpread"` - Quantity fixedpoint.Value `json:"quantity"` - Ratio fixedpoint.Value `json:"ratio"` + Ratio fixedpoint.Value `json:"ratio"` + + pivot *indicator.Pivot + orderExecutor *bbgo.GeneralOrderExecutor + session *bbgo.ExchangeSession + activeOrders *bbgo.ActiveOrderBook + currentSupportPrice fixedpoint.Value + + triggeredPrices []fixedpoint.Value } -type Entry struct { - CatBounceRatio fixedpoint.Value `json:"catBounceRatio"` - NumLayers int `json:"numLayers"` - TotalQuantity fixedpoint.Value `json:"totalQuantity"` +func (s *SupportTakeProfit) Subscribe(session *bbgo.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} - Quantity fixedpoint.Value `json:"quantity"` - MarginSideEffect types.MarginOrderSideEffectType `json:"marginOrderSideEffect"` +func (s *SupportTakeProfit) Bind(session *bbgo.ExchangeSession, orderExecutor *bbgo.GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + s.activeOrders = bbgo.NewActiveOrderBook(s.Symbol) + session.UserDataStream.OnOrderUpdate(func(order types.Order) { + if s.activeOrders.Exists(order) { + if !s.currentSupportPrice.IsZero() { + s.triggeredPrices = append(s.triggeredPrices, s.currentSupportPrice) + } + } + }) + s.activeOrders.BindStream(session.UserDataStream) + + position := orderExecutor.Position() + symbol := position.Symbol + store, _ := session.MarketDataStore(symbol) + s.pivot = &indicator.Pivot{IntervalWindow: s.IntervalWindow} + s.pivot.Bind(store) + preloadPivot(s.pivot, store) + + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + if !s.updateSupportPrice(kline.Close) { + return + } + + if !position.IsOpened(kline.Close) { + log.Infof("position is not opened, skip updating support take profit order") + return + } + + buyPrice := s.currentSupportPrice.Mul(one.Add(s.Ratio)) + quantity := position.GetQuantity() + ctx := context.Background() + + if err := orderExecutor.GracefulCancelActiveOrderBook(ctx, s.activeOrders); err != nil { + log.WithError(err).Errorf("cancel order failed") + } + + bbgo.Notify("placing %s take profit order at price %f", s.Symbol, buyPrice.Float64()) + createdOrders, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: symbol, + Type: types.OrderTypeLimitMaker, + Side: types.SideTypeBuy, + Price: buyPrice, + Quantity: quantity, + Tag: "supportTakeProfit", + }) + + if err != nil { + log.WithError(err).Errorf("can not submit orders: %+v", createdOrders) + } + + s.activeOrders.Add(createdOrders...) + })) +} + +func (s *SupportTakeProfit) updateSupportPrice(closePrice fixedpoint.Value) bool { + log.Infof("[supportTakeProfit] lows: %v", s.pivot.Lows) + + groupDistance := 0.01 + minDistance := 0.05 + supportPrices := findPossibleSupportPrices(closePrice.Float64()*(1.0-minDistance), groupDistance, s.pivot.Lows) + if len(supportPrices) == 0 { + return false + } + + log.Infof("[supportTakeProfit] found possible support prices: %v", supportPrices) + + // nextSupportPrice are sorted in increasing order + nextSupportPrice := fixedpoint.NewFromFloat(supportPrices[len(supportPrices)-1]) + + // it's price that we have been used to take profit + for _, p := range s.triggeredPrices { + var l = p.Mul(one.Sub(fixedpoint.NewFromFloat(0.01))) + var h = p.Mul(one.Add(fixedpoint.NewFromFloat(0.01))) + if p.Compare(l) > 0 && p.Compare(h) < 0 { + return false + } + } + + currentBuyPrice := s.currentSupportPrice.Mul(one.Add(s.Ratio)) + + if s.currentSupportPrice.IsZero() { + log.Infof("setup next support take profit price at %f", nextSupportPrice.Float64()) + s.currentSupportPrice = nextSupportPrice + return true + } + + // the close price is already lower than the support price, than we should update + if closePrice.Compare(currentBuyPrice) < 0 || nextSupportPrice.Compare(s.currentSupportPrice) > 0 { + log.Infof("setup next support take profit price at %f", nextSupportPrice.Float64()) + s.currentSupportPrice = nextSupportPrice + return true + } + + return false } type Strategy struct { - *bbgo.Graceful - Environment *bbgo.Environment Symbol string `json:"symbol"` Market types.Market @@ -83,25 +160,19 @@ type Strategy struct { ProfitStats *types.ProfitStats `persistence:"profit_stats"` TradeStats *types.TradeStats `persistence:"trade_stats"` - BreakLow BreakLow `json:"breakLow"` + // BreakLow is one of the entry method + BreakLow *BreakLow `json:"breakLow"` - BounceShort *BounceShort `json:"bounceShort"` + // ResistanceShort is one of the entry method + ResistanceShort *ResistanceShort `json:"resistanceShort"` - Entry Entry `json:"entry"` - ExitMethods []bbgo.ExitMethod `json:"exits"` + SupportTakeProfit []*SupportTakeProfit `json:"supportTakeProfit"` + + ExitMethods bbgo.ExitMethodSet `json:"exits"` session *bbgo.ExchangeSession orderExecutor *bbgo.GeneralOrderExecutor - stopLossPrice fixedpoint.Value - lastLow fixedpoint.Value - pivot *indicator.Pivot - resistancePivot *indicator.Pivot - stopEWMA *indicator.EWMA - pivotLowPrices []fixedpoint.Value - resistancePrices []float64 - currentBounceShortPrice fixedpoint.Value - // StrategyController bbgo.StrategyController } @@ -114,55 +185,27 @@ func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: types.Interval1m}) - if s.BounceShort != nil && s.BounceShort.Enabled { - session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.BounceShort.Interval}) + if s.ResistanceShort != nil && s.ResistanceShort.Enabled { + dynamic.InheritStructValues(s.ResistanceShort, s) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.ResistanceShort.Interval}) + } + + if s.BreakLow != nil { + dynamic.InheritStructValues(s.BreakLow, s) + s.BreakLow.Subscribe(session) + } + + for i := range s.SupportTakeProfit { + m := s.SupportTakeProfit[i] + dynamic.InheritStructValues(m, s) + m.Subscribe(session) } if !bbgo.IsBackTesting { session.Subscribe(types.MarketTradeChannel, s.Symbol, types.SubscribeOptions{}) } -} -func (s *Strategy) useQuantityOrBaseBalance(quantity fixedpoint.Value) fixedpoint.Value { - balance, hasBalance := s.session.Account.Balance(s.Market.BaseCurrency) - - if hasBalance { - if quantity.IsZero() { - bbgo.Notify("sell quantity is not set, submitting sell with all base balance: %s", balance.Available.String()) - quantity = balance.Available - } else { - quantity = fixedpoint.Min(quantity, balance.Available) - } - } - - if quantity.IsZero() { - log.Errorf("quantity is zero, can not submit sell order, please check settings") - } - - return quantity -} - -func (s *Strategy) placeLimitSell(ctx context.Context, price, quantity fixedpoint.Value, tag string) { - _, _ = s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ - Symbol: s.Symbol, - Price: price, - Side: types.SideTypeSell, - Type: types.OrderTypeLimit, - Quantity: quantity, - MarginSideEffect: types.SideEffectTypeMarginBuy, - Tag: tag, - }) -} - -func (s *Strategy) placeMarketSell(ctx context.Context, quantity fixedpoint.Value, tag string) { - _, _ = s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ - Symbol: s.Symbol, - Side: types.SideTypeSell, - Type: types.OrderTypeMarket, - Quantity: quantity, - MarginSideEffect: types.SideEffectTypeMarginBuy, - Tag: tag, - }) + s.ExitMethods.SetAndSubscribe(session, s) } func (s *Strategy) InstanceID() string { @@ -174,7 +217,6 @@ func (s *Strategy) CurrentPosition() *types.Position { } func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { - bbgo.Notify("Closing position", s.Position) return s.orderExecutor.ClosePosition(ctx, percentage) } @@ -190,11 +232,9 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } if s.TradeStats == nil { - s.TradeStats = &types.TradeStats{} + s.TradeStats = types.NewTradeStats(s.Symbol) } - s.lastLow = fixedpoint.Zero - // StrategyController s.Status = types.StrategyStatusRunning @@ -221,260 +261,33 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se }) s.orderExecutor.Bind() - store, _ := session.MarketDataStore(s.Symbol) - standardIndicator, _ := session.StandardIndicatorSet(s.Symbol) - - s.pivot = &indicator.Pivot{IntervalWindow: s.IntervalWindow} - s.pivot.Bind(store) - - lastKLine := s.preloadPivot(s.pivot, store) - - // update pivot low data - session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { - if kline.Symbol != s.Symbol || kline.Interval != s.Interval { - return - } - - lastLow := fixedpoint.NewFromFloat(s.pivot.LastLow()) - if lastLow.IsZero() { - return - } - - if lastLow.Compare(s.lastLow) != 0 { - log.Infof("new pivot low detected: %f %s", s.pivot.LastLow(), kline.EndTime.Time()) - } - - s.lastLow = lastLow - s.pivotLowPrices = append(s.pivotLowPrices, s.lastLow) - }) - - if s.BounceShort != nil && s.BounceShort.Enabled { - s.resistancePivot = &indicator.Pivot{IntervalWindow: s.BounceShort.IntervalWindow} - s.resistancePivot.Bind(store) - } - - if s.BreakLow.StopEMA != nil { - s.stopEWMA = standardIndicator.EWMA(*s.BreakLow.StopEMA) - } - for _, method := range s.ExitMethods { method.Bind(session, s.orderExecutor) } - if s.BounceShort != nil && s.BounceShort.Enabled { - if s.resistancePivot != nil { - s.preloadPivot(s.resistancePivot, store) - } - - session.UserDataStream.OnStart(func() { - if lastKLine == nil { - return - } - - if s.resistancePivot != nil { - lows := s.resistancePivot.Lows - minDistance := s.BounceShort.MinDistance.Float64() - closePrice := lastKLine.Close.Float64() - s.resistancePrices = findPossibleResistancePrices(closePrice, minDistance, lows) - log.Infof("last price: %f, possible resistance prices: %+v", closePrice, s.resistancePrices) - - if len(s.resistancePrices) > 0 { - resistancePrice := fixedpoint.NewFromFloat(s.resistancePrices[0]) - if resistancePrice.Compare(s.currentBounceShortPrice) != 0 { - log.Infof("updating resistance price... possible resistance prices: %+v", s.resistancePrices) - - _ = s.orderExecutor.GracefulCancel(ctx) - - s.currentBounceShortPrice = resistancePrice - s.placeBounceSellOrders(ctx, s.currentBounceShortPrice) - } - } - } - }) + if s.ResistanceShort != nil && s.ResistanceShort.Enabled { + s.ResistanceShort.Bind(session, s.orderExecutor) } - // Always check whether you can open a short position or not - session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { - if s.Status != types.StrategyStatusRunning { - return - } - - if kline.Symbol != s.Symbol || kline.Interval != types.Interval1m { - return - } - - if !s.Position.IsClosed() && !s.Position.IsDust(kline.Close) { - return - } - - if len(s.pivotLowPrices) == 0 { - log.Infof("currently there is no pivot low prices, skip placing orders...") - return - } - - previousLow := s.pivotLowPrices[len(s.pivotLowPrices)-1] - - // truncate the pivot low prices - if len(s.pivotLowPrices) > 10 { - s.pivotLowPrices = s.pivotLowPrices[len(s.pivotLowPrices)-10:] - } - - ratio := fixedpoint.One.Add(s.BreakLow.Ratio) - breakPrice := previousLow.Mul(ratio) - - openPrice := kline.Open - closePrice := kline.Close - // if previous low is not break, skip - if closePrice.Compare(breakPrice) >= 0 { - return - } - - // we need the price cross the break line - // or we do nothing - if !(openPrice.Compare(breakPrice) > 0 && closePrice.Compare(breakPrice) < 0) { - return - } - - log.Infof("%s breakLow signal detected, closed price %f < breakPrice %f", kline.Symbol, closePrice.Float64(), breakPrice.Float64()) - - // stop EMA protection - if s.stopEWMA != nil && !s.BreakLow.StopEMARange.IsZero() { - ema := fixedpoint.NewFromFloat(s.stopEWMA.Last()) - if ema.IsZero() { - return - } - - emaStopShortPrice := ema.Mul(fixedpoint.One.Sub(s.BreakLow.StopEMARange)) - if closePrice.Compare(emaStopShortPrice) < 0 { - log.Infof("stopEMA protection: close price %f < EMA(%v) = %f", closePrice.Float64(), s.BreakLow.StopEMA, ema.Float64()) - return - } - } - - _ = s.orderExecutor.GracefulCancel(ctx) - - quantity := s.useQuantityOrBaseBalance(s.BreakLow.Quantity) - if s.BreakLow.MarketOrder { - bbgo.Notify("%s price %f breaks the previous low %f with ratio %f, submitting market sell to open a short position", s.Symbol, kline.Close.Float64(), previousLow.Float64(), s.BreakLow.Ratio.Float64()) - s.placeMarketSell(ctx, quantity, "breakLowMarket") - } else { - sellPrice := previousLow.Mul(fixedpoint.One.Add(s.BreakLow.BounceRatio)) - - bbgo.Notify("%s price %f breaks the previous low %f with ratio %f, submitting limit sell @ %f", s.Symbol, kline.Close.Float64(), previousLow.Float64(), s.BreakLow.Ratio.Float64(), sellPrice.Float64()) - s.placeLimitSell(ctx, sellPrice, quantity, "breakLowLimit") - } - }) - - session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { - // StrategyController - if s.Status != types.StrategyStatusRunning { - return - } - - if s.BounceShort == nil || !s.BounceShort.Enabled { - return - } - - if kline.Symbol != s.Symbol || kline.Interval != s.BounceShort.Interval { - return - } - - if s.resistancePivot != nil { - closePrice := kline.Close.Float64() - minDistance := s.BounceShort.MinDistance.Float64() - lows := s.resistancePivot.Lows - s.resistancePrices = findPossibleResistancePrices(closePrice, minDistance, lows) - - if len(s.resistancePrices) > 0 { - resistancePrice := fixedpoint.NewFromFloat(s.resistancePrices[0]) - if resistancePrice.Compare(s.currentBounceShortPrice) != 0 { - log.Infof("updating resistance price... possible resistance prices: %+v", s.resistancePrices) - - _ = s.orderExecutor.GracefulCancel(ctx) - - s.currentBounceShortPrice = resistancePrice - s.placeBounceSellOrders(ctx, s.currentBounceShortPrice) - } - } - } - }) - - if !bbgo.IsBackTesting { - // use market trade to submit short order - session.MarketDataStream.OnMarketTrade(func(trade types.Trade) { - - }) + if s.BreakLow != nil { + s.BreakLow.Bind(session, s.orderExecutor) } - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + for i := range s.SupportTakeProfit { + s.SupportTakeProfit[i].Bind(session, s.orderExecutor) + } + + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) - wg.Done() + _ = s.orderExecutor.GracefulCancel(ctx) }) return nil } -func (s *Strategy) findHigherPivotLow(price fixedpoint.Value) (fixedpoint.Value, bool) { - for l := len(s.pivotLowPrices) - 1; l > 0; l-- { - if s.pivotLowPrices[l].Compare(price) > 0 { - return s.pivotLowPrices[l], true - } - } - - return price, false -} - -func (s *Strategy) placeBounceSellOrders(ctx context.Context, resistancePrice fixedpoint.Value) { - futuresMode := s.session.Futures || s.session.IsolatedFutures - totalQuantity := s.BounceShort.Quantity - numLayers := s.BounceShort.NumLayers - if numLayers == 0 { - numLayers = 1 - } - - numLayersF := fixedpoint.NewFromInt(int64(numLayers)) - - layerSpread := s.BounceShort.LayerSpread - quantity := totalQuantity.Div(numLayersF) - - log.Infof("placing bounce short orders: resistance price = %f, layer quantity = %f, num of layers = %d", resistancePrice.Float64(), quantity.Float64(), numLayers) - - for i := 0; i < numLayers; i++ { - balances := s.session.GetAccount().Balances() - quoteBalance := balances[s.Market.QuoteCurrency] - baseBalance := balances[s.Market.BaseCurrency] - - // price = (resistance_price * (1.0 + ratio)) * ((1.0 + layerSpread) * i) - price := resistancePrice.Mul(fixedpoint.One.Add(s.BounceShort.Ratio)) - spread := layerSpread.Mul(fixedpoint.NewFromInt(int64(i))) - price = price.Add(spread) - log.Infof("price = %f", price.Float64()) - - log.Infof("placing bounce short order #%d: price = %f, quantity = %f", i, price.Float64(), quantity.Float64()) - - if futuresMode { - if quantity.Mul(price).Compare(quoteBalance.Available) <= 0 { - s.placeOrder(ctx, price, quantity) - } - } else { - if quantity.Compare(baseBalance.Available) <= 0 { - s.placeOrder(ctx, price, quantity) - } - } - } -} - -func (s *Strategy) placeOrder(ctx context.Context, price fixedpoint.Value, quantity fixedpoint.Value) { - _, _ = s.orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ - Symbol: s.Symbol, - Side: types.SideTypeSell, - Type: types.OrderTypeLimitMaker, - Price: price, - Quantity: quantity, - }) -} - -func (s *Strategy) preloadPivot(pivot *indicator.Pivot, store *bbgo.MarketDataStore) *types.KLine { +func preloadPivot(pivot *indicator.Pivot, store *bbgo.MarketDataStore) *types.KLine { klines, ok := store.KLinesOfInterval(pivot.Interval) if !ok { return nil @@ -487,31 +300,7 @@ func (s *Strategy) preloadPivot(pivot *indicator.Pivot, store *bbgo.MarketDataSt pivot.Update((*klines)[0 : i+1]) } - log.Infof("found %s %v previous lows: %v", s.Symbol, pivot.IntervalWindow, pivot.Lows) - log.Infof("found %s %v previous highs: %v", s.Symbol, pivot.IntervalWindow, pivot.Highs) + log.Debugf("found %v previous lows: %v", pivot.IntervalWindow, pivot.Lows) + log.Debugf("found %v previous highs: %v", pivot.IntervalWindow, pivot.Highs) return &last } - -func findPossibleResistancePrices(closePrice float64, minDistance float64, lows []float64) []float64 { - // sort float64 in increasing order - sort.Float64s(lows) - - var resistancePrices []float64 - for _, low := range lows { - if low < closePrice { - continue - } - - last := closePrice - if len(resistancePrices) > 0 { - last = resistancePrices[len(resistancePrices)-1] - } - - if (low / last) < (1.0 + minDistance) { - continue - } - resistancePrices = append(resistancePrices, low) - } - - return resistancePrices -} diff --git a/pkg/strategy/rsmaker/strategy.go b/pkg/strategy/rsmaker/strategy.go index 0d0c5a8fe..1eada96c2 100644 --- a/pkg/strategy/rsmaker/strategy.go +++ b/pkg/strategy/rsmaker/strategy.go @@ -29,9 +29,6 @@ func init() { } type Strategy struct { - *bbgo.Graceful - *bbgo.Notifiability - Environment *bbgo.Environment StandardIndicatorSet *bbgo.StandardIndicatorSet Market types.Market @@ -416,7 +413,7 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo. } if s.TradeStats == nil { - s.TradeStats = &types.TradeStats{} + s.TradeStats = types.NewTradeStats(s.Symbol) } // initial required information diff --git a/pkg/strategy/supertrend/strategy.go b/pkg/strategy/supertrend/strategy.go index 5b5d07acc..29d615bc8 100644 --- a/pkg/strategy/supertrend/strategy.go +++ b/pkg/strategy/supertrend/strategy.go @@ -33,7 +33,6 @@ func init() { } type Strategy struct { - *bbgo.Graceful *bbgo.Persistence Environment *bbgo.Environment @@ -430,7 +429,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se }) // Graceful shutdown - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() close(s.stopC) diff --git a/pkg/strategy/support/strategy.go b/pkg/strategy/support/strategy.go index 4a02e443a..126bdadfe 100644 --- a/pkg/strategy/support/strategy.go +++ b/pkg/strategy/support/strategy.go @@ -134,7 +134,6 @@ func (control *TrailingStopControl) GenerateStopOrder(quantity fixedpoint.Value) type Strategy struct { *bbgo.Persistence `json:"-"` *bbgo.Environment `json:"-"` - *bbgo.Graceful `json:"-"` session *bbgo.ExchangeSession @@ -335,7 +334,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se // trade stats if s.TradeStats == nil { - s.TradeStats = &types.TradeStats{} + s.TradeStats = types.NewTradeStats(s.Symbol) } s.orderExecutor = bbgo.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) @@ -582,7 +581,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } }) - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() // Cancel trailing stop order diff --git a/pkg/strategy/wall/strategy.go b/pkg/strategy/wall/strategy.go index 824cc28cf..ffc9cef4a 100644 --- a/pkg/strategy/wall/strategy.go +++ b/pkg/strategy/wall/strategy.go @@ -30,7 +30,6 @@ func init() { } type Strategy struct { - *bbgo.Graceful *bbgo.Persistence Environment *bbgo.Environment @@ -377,7 +376,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } }() - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() close(s.stopC) diff --git a/pkg/strategy/xbalance/strategy.go b/pkg/strategy/xbalance/strategy.go index 60f0ac6ae..ea4aa1e04 100644 --- a/pkg/strategy/xbalance/strategy.go +++ b/pkg/strategy/xbalance/strategy.go @@ -135,8 +135,6 @@ func (a *Address) UnmarshalJSON(body []byte) error { } type Strategy struct { - *bbgo.Graceful - Interval types.Duration `json:"interval"` Addresses map[string]Address `json:"addresses"` @@ -342,7 +340,7 @@ func (s *Strategy) CrossRun(ctx context.Context, _ bbgo.OrderExecutionRouter, se s.State = s.newDefaultState() } - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() }) diff --git a/pkg/strategy/xgap/strategy.go b/pkg/strategy/xgap/strategy.go index 102ea2a42..7406e5551 100644 --- a/pkg/strategy/xgap/strategy.go +++ b/pkg/strategy/xgap/strategy.go @@ -57,7 +57,6 @@ func (s *State) Reset() { } type Strategy struct { - *bbgo.Graceful *bbgo.Persistence Symbol string `json:"symbol"` @@ -193,7 +192,7 @@ func (s *Strategy) CrossRun(ctx context.Context, _ bbgo.OrderExecutionRouter, se } } - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() close(s.stopC) diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index 289b94208..aca499413 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -33,7 +33,6 @@ func init() { } type Strategy struct { - *bbgo.Graceful *bbgo.Persistence Environment *bbgo.Environment @@ -879,7 +878,7 @@ func (s *Strategy) CrossRun(ctx context.Context, orderExecutionRouter bbgo.Order } }() - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() close(s.stopC) diff --git a/pkg/strategy/xnav/strategy.go b/pkg/strategy/xnav/strategy.go index f11582efb..ea9526f47 100644 --- a/pkg/strategy/xnav/strategy.go +++ b/pkg/strategy/xnav/strategy.go @@ -58,7 +58,6 @@ func (s *State) Reset() { } type Strategy struct { - *bbgo.Graceful *bbgo.Persistence *bbgo.Environment @@ -180,7 +179,7 @@ func (s *Strategy) CrossRun(ctx context.Context, _ bbgo.OrderExecutionRouter, se return err } - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + bbgo.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() s.SaveState() diff --git a/pkg/types/exchange.go b/pkg/types/exchange.go index 5bff2689c..e026decb4 100644 --- a/pkg/types/exchange.go +++ b/pkg/types/exchange.go @@ -74,6 +74,7 @@ func ValidExchangeName(a string) (ExchangeName, error) { return "", fmt.Errorf("invalid exchange name: %s", a) } +//go:generate mockgen -destination=mocks/mock_exchange.go -package=mocks . Exchange type Exchange interface { Name() ExchangeName diff --git a/pkg/types/indicator.go b/pkg/types/indicator.go index fb7bf8b32..a6dea330f 100644 --- a/pkg/types/indicator.go +++ b/pkg/types/indicator.go @@ -11,15 +11,18 @@ import ( // Super basic Series type that simply holds the float64 data // with size limit (the only difference compare to float64slice) type Queue struct { + SeriesBase arr []float64 size int } func NewQueue(size int) *Queue { - return &Queue{ + out := &Queue{ arr: make([]float64, 0, size), size: size, } + out.SeriesBase.Series = out + return out } func (inc *Queue) Last() float64 { @@ -47,7 +50,7 @@ func (inc *Queue) Update(v float64) { } } -var _ Series = &Queue{} +var _ SeriesExtend = &Queue{} // Float64Indicator is the indicators (SMA and EWMA) that we want to use are returning float64 data. type Float64Indicator interface { @@ -82,24 +85,24 @@ type SeriesExtend interface { Array(limit ...int) (result []float64) Reverse(limit ...int) (result Float64Slice) Change(offset ...int) SeriesExtend - Stdev(length int) float64 + PercentageChange(offset ...int) SeriesExtend + Stdev(params ...int) float64 + Rolling(window int) *RollingResult + Shift(offset int) SeriesExtend + Skew(length int) float64 + Variance(length int) float64 + Covariance(b Series, length int) float64 + Correlation(b Series, length int, method ...CorrFunc) float64 + Rank(length int) SeriesExtend } -type IndexFuncType func(int) float64 -type LastFuncType func() float64 -type LengthFuncType func() int - type SeriesBase struct { - index IndexFuncType - last LastFuncType - length LengthFuncType + Series } func NewSeries(a Series) SeriesExtend { return &SeriesBase{ - index: a.Index, - last: a.Last, - length: a.Length, + Series: a, } } @@ -108,6 +111,11 @@ type UpdatableSeries interface { Update(float64) } +type UpdatableSeriesExtend interface { + SeriesExtend + Update(float64) +} + // The interface maps to pinescript basic type `series` for bool type // Access the internal historical data from the latest to the oldest // Index(0) always maps to Last() @@ -595,14 +603,282 @@ func Change(a Series, offset ...int) SeriesExtend { return NewSeries(&ChangeResult{a, o}) } -func Stdev(a Series, length int) float64 { +type PercentageChangeResult struct { + a Series + offset int +} + +func (c *PercentageChangeResult) Last() float64 { + if c.offset >= c.a.Length() { + return 0 + } + return c.a.Last()/c.a.Index(c.offset) - 1 +} + +func (c *PercentageChangeResult) Index(i int) float64 { + if i+c.offset >= c.a.Length() { + return 0 + } + return c.a.Index(i)/c.a.Index(i+c.offset) - 1 +} + +func (c *PercentageChangeResult) Length() int { + length := c.a.Length() + if length >= c.offset { + return length - c.offset + } + return 0 +} + +// Percentage change between current and a prior element, a / a[offset] - 1. +// offset: if not give, offset is 1. +func PercentageChange(a Series, offset ...int) SeriesExtend { + o := 1 + if len(offset) > 0 { + o = offset[0] + } + + return NewSeries(&PercentageChangeResult{a, o}) +} + +func Stdev(a Series, params ...int) float64 { + length := a.Length() + if len(params) > 0 { + if params[0] < length { + length = params[0] + } + } + ddof := 0 + if len(params) > 1 { + ddof = params[1] + } avg := Mean(a, length) s := .0 for i := 0; i < length; i++ { diff := a.Index(i) - avg s += diff * diff } - return math.Sqrt(s / float64(length)) + return math.Sqrt(s / float64(length-ddof)) +} + +type CorrFunc func(Series, Series, int) float64 + +func Kendall(a, b Series, length int) float64 { + if a.Length() < length { + length = a.Length() + } + if b.Length() < length { + length = b.Length() + } + aRanks := Rank(a, length) + bRanks := Rank(b, length) + concordant, discordant := 0, 0 + for i := 0; i < length; i++ { + for j := i + 1; j < length; j++ { + value := (aRanks.Index(i) - aRanks.Index(j)) * (bRanks.Index(i) - bRanks.Index(j)) + if value > 0 { + concordant++ + } else { + discordant++ + } + } + } + return float64(concordant-discordant) * 2.0 / float64(length*(length-1)) +} + +func Rank(a Series, length int) SeriesExtend { + if length > a.Length() { + length = a.Length() + } + rank := make([]float64, length) + mapper := make([]float64, length+1) + for i := length - 1; i >= 0; i-- { + ii := a.Index(i) + counter := 0. + for j := 0; j < length; j++ { + if a.Index(j) <= ii { + counter += 1. + } + } + rank[i] = counter + mapper[int(counter)] += 1. + } + output := NewQueue(length) + for i := length - 1; i >= 0; i-- { + output.Update(rank[i] - (mapper[int(rank[i])]-1.)/2) + } + return output +} + +func Pearson(a, b Series, length int) float64 { + if a.Length() < length { + length = a.Length() + } + if b.Length() < length { + length = b.Length() + } + x := make([]float64, length) + y := make([]float64, length) + for i := 0; i < length; i++ { + x[i] = a.Index(i) + y[i] = b.Index(i) + } + return stat.Correlation(x, y, nil) +} + +func Spearman(a, b Series, length int) float64 { + if a.Length() < length { + length = a.Length() + } + if b.Length() < length { + length = b.Length() + } + aRank := Rank(a, length) + bRank := Rank(b, length) + return Pearson(aRank, bRank, length) +} + +// similar to pandas.Series.corr() function. +// +// method could either be `types.Pearson`, `types.Spearman` or `types.Kendall` +func Correlation(a Series, b Series, length int, method ...CorrFunc) float64 { + var runner CorrFunc + if len(method) == 0 { + runner = Pearson + } else { + runner = method[0] + } + return runner(a, b, length) +} + +// similar to pandas.Series.cov() function with ddof=0 +// +// Compute covariance with Series +func Covariance(a Series, b Series, length int) float64 { + if a.Length() < length { + length = a.Length() + } + if b.Length() < length { + length = b.Length() + } + + meana := Mean(a, length) + meanb := Mean(b, length) + sum := 0.0 + for i := 0; i < length; i++ { + sum += (a.Index(i) - meana) * (b.Index(i) - meanb) + } + sum /= float64(length) + return sum +} + +func Variance(a Series, length int) float64 { + return Covariance(a, a, length) +} + +// similar to pandas.Series.skew() function. +// +// Return unbiased skew over input series +func Skew(a Series, length int) float64 { + if length > a.Length() { + length = a.Length() + } + mean := Mean(a, length) + sum2 := 0.0 + sum3 := 0.0 + for i := 0; i < length; i++ { + diff := a.Index(i) - mean + sum2 += diff * diff + sum3 += diff * diff * diff + } + if length <= 2 || sum2 == 0 { + return math.NaN() + } + l := float64(length) + return l * math.Sqrt(l-1) / (l - 2) * sum3 / math.Pow(sum2, 1.5) +} + +type ShiftResult struct { + a Series + offset int +} + +func (inc *ShiftResult) Last() float64 { + if inc.offset < 0 { + return 0 + } + if inc.offset > inc.a.Length() { + return 0 + } + return inc.a.Index(inc.offset) +} +func (inc *ShiftResult) Index(i int) float64 { + if inc.offset+i < 0 { + return 0 + } + if inc.offset+i > inc.a.Length() { + return 0 + } + return inc.a.Index(inc.offset + i) +} + +func (inc *ShiftResult) Length() int { + return inc.a.Length() - inc.offset +} + +func Shift(a Series, offset int) SeriesExtend { + return NewSeries(&ShiftResult{a, offset}) +} + +type RollingResult struct { + a Series + window int +} + +type SliceView struct { + a Series + start int + length int +} + +func (s *SliceView) Last() float64 { + return s.a.Index(s.start) +} +func (s *SliceView) Index(i int) float64 { + if i >= s.length { + return 0 + } + return s.a.Index(i + s.start) +} + +func (s *SliceView) Length() int { + return s.length +} + +var _ Series = &SliceView{} + +func (r *RollingResult) Last() SeriesExtend { + return NewSeries(&SliceView{r.a, 0, r.window}) +} + +func (r *RollingResult) Index(i int) SeriesExtend { + if i*r.window > r.a.Length() { + return nil + } + return NewSeries(&SliceView{r.a, i * r.window, r.window}) +} + +func (r *RollingResult) Length() int { + mod := r.a.Length() % r.window + if mod > 0 { + return r.a.Length()/r.window + 1 + } else { + return r.a.Length() / r.window + } +} + +func Rolling(a Series, window int) *RollingResult { + return &RollingResult{a, window} } // TODO: ta.linreg diff --git a/pkg/types/indicator_test.go b/pkg/types/indicator_test.go index 8919c7866..82517b449 100644 --- a/pkg/types/indicator_test.go +++ b/pkg/types/indicator_test.go @@ -33,3 +33,54 @@ func TestFloat64Slice(t *testing.T) { b = append(b, 3.0) assert.Equal(t, c.Last(), 1.) } + +/* +python + +import pandas as pd +s1 = pd.Series([.2, 0., .6, .2, .2]) +s2 = pd.Series([.3, .6, .0, .1]) +print(s1.corr(s2, method='pearson')) +print(s1.corr(s2, method='spearman') +print(s1.corr(s2, method='kendall')) +print(s1.rank()) +*/ +func TestCorr(t *testing.T) { + var a = Float64Slice{.2, .0, .6, .2} + var b = Float64Slice{.3, .6, .0, .1} + corr := Correlation(&a, &b, 4, Pearson) + assert.InDelta(t, corr, -0.8510644, 0.001) + out := Rank(&a, 4) + assert.Equal(t, out.Index(0), 2.5) + assert.Equal(t, out.Index(1), 4.0) + corr = Correlation(&a, &b, 4, Spearman) + assert.InDelta(t, corr, -0.94868, 0.001) +} + +/* +python + +import pandas as pd +s1 = pd.Series([.2, 0., .6, .2, .2]) +s2 = pd.Series([.3, .6, .0, .1]) +print(s1.cov(s2, ddof=0)) +*/ +func TestCov(t *testing.T) { + var a = Float64Slice{.2, .0, .6, .2} + var b = Float64Slice{.3, .6, .0, .1} + cov := Covariance(&a, &b, 4) + assert.InDelta(t, cov, -0.042499, 0.001) +} + +/* +python + +import pandas as pd +s1 = pd.Series([.2, 0., .6, .2, .2]) +print(s1.skew()) +*/ +func TestSkew(t *testing.T) { + var a = Float64Slice{.2, .0, .6, .2} + sk := Skew(&a, 4) + assert.InDelta(t, sk, 1.129338, 0.001) +} diff --git a/pkg/types/kline.go b/pkg/types/kline.go index 6a92ffdf3..529d30d95 100644 --- a/pkg/types/kline.go +++ b/pkg/types/kline.go @@ -604,3 +604,14 @@ func (k *KLineSeries) Length() int { } var _ Series = &KLineSeries{} + +type KLineCallBack func(k KLine) + +func KLineWith(symbol string, interval Interval, callback KLineCallBack) KLineCallBack { + return func(k KLine) { + if k.Symbol != symbol || k.Interval != interval { + return + } + callback(k) + } +} diff --git a/pkg/types/mocks/mock_exchange.go b/pkg/types/mocks/mock_exchange.go new file mode 100644 index 000000000..731ab94de --- /dev/null +++ b/pkg/types/mocks/mock_exchange.go @@ -0,0 +1,227 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/c9s/bbgo/pkg/types (interfaces: Exchange) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + types "github.com/c9s/bbgo/pkg/types" + gomock "github.com/golang/mock/gomock" +) + +// MockExchange is a mock of Exchange interface. +type MockExchange struct { + ctrl *gomock.Controller + recorder *MockExchangeMockRecorder +} + +// MockExchangeMockRecorder is the mock recorder for MockExchange. +type MockExchangeMockRecorder struct { + mock *MockExchange +} + +// NewMockExchange creates a new mock instance. +func NewMockExchange(ctrl *gomock.Controller) *MockExchange { + mock := &MockExchange{ctrl: ctrl} + mock.recorder = &MockExchangeMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockExchange) EXPECT() *MockExchangeMockRecorder { + return m.recorder +} + +// CancelOrders mocks base method. +func (m *MockExchange) CancelOrders(arg0 context.Context, arg1 ...types.Order) error { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "CancelOrders", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// CancelOrders indicates an expected call of CancelOrders. +func (mr *MockExchangeMockRecorder) CancelOrders(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelOrders", reflect.TypeOf((*MockExchange)(nil).CancelOrders), varargs...) +} + +// Name mocks base method. +func (m *MockExchange) Name() types.ExchangeName { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Name") + ret0, _ := ret[0].(types.ExchangeName) + return ret0 +} + +// Name indicates an expected call of Name. +func (mr *MockExchangeMockRecorder) Name() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Name", reflect.TypeOf((*MockExchange)(nil).Name)) +} + +// NewStream mocks base method. +func (m *MockExchange) NewStream() types.Stream { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewStream") + ret0, _ := ret[0].(types.Stream) + return ret0 +} + +// NewStream indicates an expected call of NewStream. +func (mr *MockExchangeMockRecorder) NewStream() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewStream", reflect.TypeOf((*MockExchange)(nil).NewStream)) +} + +// PlatformFeeCurrency mocks base method. +func (m *MockExchange) PlatformFeeCurrency() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PlatformFeeCurrency") + ret0, _ := ret[0].(string) + return ret0 +} + +// PlatformFeeCurrency indicates an expected call of PlatformFeeCurrency. +func (mr *MockExchangeMockRecorder) PlatformFeeCurrency() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PlatformFeeCurrency", reflect.TypeOf((*MockExchange)(nil).PlatformFeeCurrency)) +} + +// QueryAccount mocks base method. +func (m *MockExchange) QueryAccount(arg0 context.Context) (*types.Account, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryAccount", arg0) + ret0, _ := ret[0].(*types.Account) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryAccount indicates an expected call of QueryAccount. +func (mr *MockExchangeMockRecorder) QueryAccount(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryAccount", reflect.TypeOf((*MockExchange)(nil).QueryAccount), arg0) +} + +// QueryAccountBalances mocks base method. +func (m *MockExchange) QueryAccountBalances(arg0 context.Context) (types.BalanceMap, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryAccountBalances", arg0) + ret0, _ := ret[0].(types.BalanceMap) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryAccountBalances indicates an expected call of QueryAccountBalances. +func (mr *MockExchangeMockRecorder) QueryAccountBalances(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryAccountBalances", reflect.TypeOf((*MockExchange)(nil).QueryAccountBalances), arg0) +} + +// QueryKLines mocks base method. +func (m *MockExchange) QueryKLines(arg0 context.Context, arg1 string, arg2 types.Interval, arg3 types.KLineQueryOptions) ([]types.KLine, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryKLines", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].([]types.KLine) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryKLines indicates an expected call of QueryKLines. +func (mr *MockExchangeMockRecorder) QueryKLines(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryKLines", reflect.TypeOf((*MockExchange)(nil).QueryKLines), arg0, arg1, arg2, arg3) +} + +// QueryMarkets mocks base method. +func (m *MockExchange) QueryMarkets(arg0 context.Context) (types.MarketMap, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryMarkets", arg0) + ret0, _ := ret[0].(types.MarketMap) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryMarkets indicates an expected call of QueryMarkets. +func (mr *MockExchangeMockRecorder) QueryMarkets(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryMarkets", reflect.TypeOf((*MockExchange)(nil).QueryMarkets), arg0) +} + +// QueryOpenOrders mocks base method. +func (m *MockExchange) QueryOpenOrders(arg0 context.Context, arg1 string) ([]types.Order, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryOpenOrders", arg0, arg1) + ret0, _ := ret[0].([]types.Order) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryOpenOrders indicates an expected call of QueryOpenOrders. +func (mr *MockExchangeMockRecorder) QueryOpenOrders(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryOpenOrders", reflect.TypeOf((*MockExchange)(nil).QueryOpenOrders), arg0, arg1) +} + +// QueryTicker mocks base method. +func (m *MockExchange) QueryTicker(arg0 context.Context, arg1 string) (*types.Ticker, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryTicker", arg0, arg1) + ret0, _ := ret[0].(*types.Ticker) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryTicker indicates an expected call of QueryTicker. +func (mr *MockExchangeMockRecorder) QueryTicker(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryTicker", reflect.TypeOf((*MockExchange)(nil).QueryTicker), arg0, arg1) +} + +// QueryTickers mocks base method. +func (m *MockExchange) QueryTickers(arg0 context.Context, arg1 ...string) (map[string]types.Ticker, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "QueryTickers", varargs...) + ret0, _ := ret[0].(map[string]types.Ticker) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryTickers indicates an expected call of QueryTickers. +func (mr *MockExchangeMockRecorder) QueryTickers(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryTickers", reflect.TypeOf((*MockExchange)(nil).QueryTickers), varargs...) +} + +// SubmitOrders mocks base method. +func (m *MockExchange) SubmitOrders(arg0 context.Context, arg1 ...types.SubmitOrder) (types.OrderSlice, error) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "SubmitOrders", varargs...) + ret0, _ := ret[0].(types.OrderSlice) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SubmitOrders indicates an expected call of SubmitOrders. +func (mr *MockExchangeMockRecorder) SubmitOrders(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitOrders", reflect.TypeOf((*MockExchange)(nil).SubmitOrders), varargs...) +} diff --git a/pkg/types/position.go b/pkg/types/position.go index 185106572..9ace9018e 100644 --- a/pkg/types/position.go +++ b/pkg/types/position.go @@ -218,6 +218,10 @@ type FuturesPosition struct { } 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, @@ -273,6 +277,10 @@ 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 diff --git a/pkg/types/profit.go b/pkg/types/profit.go index c0cd6a4d5..15771ee66 100644 --- a/pkg/types/profit.go +++ b/pkg/types/profit.go @@ -204,14 +204,14 @@ type ProfitStats struct { QuoteCurrency string `json:"quoteCurrency"` BaseCurrency string `json:"baseCurrency"` - AccumulatedPnL fixedpoint.Value `json:"accumulatedPnL,omitempty"` + AccumulatedPnL fixedpoint.Value `json:"accumulatedPnL,omitempty"` AccumulatedNetProfit fixedpoint.Value `json:"accumulatedNetProfit,omitempty"` AccumulatedGrossProfit fixedpoint.Value `json:"accumulatedProfit,omitempty"` AccumulatedGrossLoss fixedpoint.Value `json:"accumulatedLoss,omitempty"` AccumulatedVolume fixedpoint.Value `json:"accumulatedVolume,omitempty"` - AccumulatedSince int64 `json:"accumulatedSince,omitempty"` + AccumulatedSince int64 `json:"accumulatedSince,omitempty"` - TodayPnL fixedpoint.Value `json:"todayPnL,omitempty"` + TodayPnL fixedpoint.Value `json:"todayPnL,omitempty"` TodayNetProfit fixedpoint.Value `json:"todayNetProfit,omitempty"` TodayGrossProfit fixedpoint.Value `json:"todayProfit,omitempty"` TodayGrossLoss fixedpoint.Value `json:"todayLoss,omitempty"` @@ -279,11 +279,11 @@ func (s *ProfitStats) PlainText() string { return fmt.Sprintf("%s Profit Today\n"+ "Profit %s %s\n"+ "Net profit %s %s\n"+ - "Trade Loss %s %s\n"+ + "Gross Loss %s %s\n"+ "Summary:\n"+ "Accumulated Profit %s %s\n"+ "Accumulated Net Profit %s %s\n"+ - "Accumulated Trade Loss %s %s\n"+ + "Accumulated Gross Loss %s %s\n"+ "Since %s", s.Symbol, s.TodayPnL.String(), s.QuoteCurrency, diff --git a/pkg/types/seriesbase_imp.go b/pkg/types/seriesbase_imp.go index 021da9aca..f15317a72 100644 --- a/pkg/types/seriesbase_imp.go +++ b/pkg/types/seriesbase_imp.go @@ -1,15 +1,24 @@ package types func (s *SeriesBase) Index(i int) float64 { - return s.index(i) + if s.Series == nil { + return 0 + } + return s.Series.Index(i) } func (s *SeriesBase) Last() float64 { - return s.last() + if s.Series == nil { + return 0 + } + return s.Series.Last() } func (s *SeriesBase) Length() int { - return s.length() + if s.Series == nil { + return 0 + } + return s.Series.Length() } func (s *SeriesBase) Sum(limit ...int) float64 { @@ -80,6 +89,38 @@ func (s *SeriesBase) Change(offset ...int) SeriesExtend { return Change(s, offset...) } -func (s *SeriesBase) Stdev(length int) float64 { - return Stdev(s, length) +func (s *SeriesBase) PercentageChange(offset ...int) SeriesExtend { + return PercentageChange(s, offset...) +} + +func (s *SeriesBase) Stdev(params ...int) float64 { + return Stdev(s, params...) +} + +func (s *SeriesBase) Rolling(window int) *RollingResult { + return Rolling(s, window) +} + +func (s *SeriesBase) Shift(offset int) SeriesExtend { + return Shift(s, offset) +} + +func (s *SeriesBase) Skew(length int) float64 { + return Skew(s, length) +} + +func (s *SeriesBase) Variance(length int) float64 { + return Variance(s, length) +} + +func (s *SeriesBase) Covariance(b Series, length int) float64 { + return Covariance(s, b, length) +} + +func (s *SeriesBase) Correlation(b Series, length int, method ...CorrFunc) float64 { + return Correlation(s, b, length, method...) +} + +func (s *SeriesBase) Rank(length int) SeriesExtend { + return Rank(s, length) } diff --git a/pkg/types/subscribe.go b/pkg/types/subscribe.go deleted file mode 100644 index 324dd04cb..000000000 --- a/pkg/types/subscribe.go +++ /dev/null @@ -1,5 +0,0 @@ -package types - -type Subscriber interface { - Subscribe() -} diff --git a/pkg/types/trade_stats.go b/pkg/types/trade_stats.go index ddc31a8b2..ce0281d8c 100644 --- a/pkg/types/trade_stats.go +++ b/pkg/types/trade_stats.go @@ -9,6 +9,7 @@ import ( // TODO: Add more stats from the reference: // See https://www.metatrader5.com/en/terminal/help/algotrading/testing_report type TradeStats struct { + Symbol string `json:"symbol"` WinningRatio fixedpoint.Value `json:"winningRatio" yaml:"winningRatio"` NumOfLossTrade int `json:"numOfLossTrade" yaml:"numOfLossTrade"` NumOfProfitTrade int `json:"numOfProfitTrade" yaml:"numOfProfitTrade"` @@ -22,7 +23,19 @@ type TradeStats struct { TotalNetProfit fixedpoint.Value `json:"totalNetProfit" yaml:"totalNetProfit"` } -func (s *TradeStats) Add(pnl fixedpoint.Value) { +func NewTradeStats(symbol string) *TradeStats { + return &TradeStats{Symbol: symbol} +} + +func (s *TradeStats) Add(profit *Profit) { + if profit.Symbol != s.Symbol { + return + } + + s.add(profit.Profit) +} + +func (s *TradeStats) add(pnl fixedpoint.Value) { if pnl.Sign() > 0 { s.NumOfProfitTrade++ s.Profits = append(s.Profits, pnl) @@ -45,7 +58,7 @@ func (s *TradeStats) Add(pnl fixedpoint.Value) { s.WinningRatio = fixedpoint.NewFromFloat(float64(s.NumOfProfitTrade) / float64(s.NumOfLossTrade)) } - s.ProfitFactor = s.GrossProfit.Div(s.GrossLoss) + s.ProfitFactor = s.GrossProfit.Div(s.GrossLoss.Abs()) } func (s *TradeStats) String() string {